diff --git a/.superpowers/sdd/data-task-5-report.md b/.superpowers/sdd/data-task-5-report.md new file mode 100644 index 000000000..490907c2e --- /dev/null +++ b/.superpowers/sdd/data-task-5-report.md @@ -0,0 +1,48 @@ +# Task 5 Report: Active-Profile Training Settings + +## Scope and baseline + +- Base commit: `1a84814990db2129ccd587955eafab7f1d929af1` +- Branch: `codex/add-profile-tab` +- Scope: Task 5 only. No Task 6 work or tracked design/plan documents were changed. + +## Implemented + +- Reworked `SettingsManager` into a global-plus-active-profile compatibility facade. Profile-owned setters capture the originating Ready profile, serialize section changes, reject stale/switching writes, preserve consent/VBT cascades and clamps, and never write legacy Settings. +- Seeded the compatibility stream and every derived `StateFlow` synchronously from an already-Ready profile, avoiding transient legacy/default values for immediate consumers. +- Narrowed the `PreferencesManager` abstraction to global writes and deprecated migration-only quick-start reads. Concrete legacy profile writers remain internal solely for migration-source tests; the production-call audit found no callers. +- Added repository-level atomic `mutateCore`, used by both SettingsManager and health import, so a long health read changes only body weight against the latest core section without reverting concurrent unit/increment updates. +- Added `RequiredMigrationGate`; required-migration awaiters now terminate on Ready or throw `RequiredMigrationFailedException` on Failed. Health maps migration failure and profile-context failures to `HealthBodyWeightSyncResult.Failed`. +- Replaced the production equipment-rack binding with a lifecycle-owned active-profile repository and retained the Settings implementation only as an explicit legacy implementation. +- Routed SafeWord effective-state gating, quick-start documents and auto-saves, DWSM VBT reads, MainViewModel LED unlocks, BLE color application, and workout start gating through the active Ready profile. +- Preserved complete single-exercise documents, rack IDs, per-set arrays, and Just Lift conversion fidelity; the existing Just Lift runtime `Int` boundary intentionally rounds the persisted float. +- Made `JustLiftScreen` reload defaults on Ready profile-ID changes and suppress parameter writes while Switching or while the current profile's defaults are loading. +- Updated Koin bindings and shared Android-host/DWSM fixtures to use one Ready `FakeUserProfileRepository` per fixture. +- Replaced the adults-only dialog's former callback sequence with the serialized composite operation and removed the now-dead callback/comments. + +## TDD evidence + +- Initial consumer RED: 57 tests executed, 6 expected failures covering legacy-setting isolation, A/B rack switching, health active-core writes, SafeWord effective gating, BLE profile color, and the Ready start gate. +- Additional RED regressions: + - health profile transition surfaced an uncaught `ProfileContextUnavailableException`; + - immediate `SettingsManager.userPreferences` exposed the legacy initial snapshot; + - final review set executed 21 tests with 4 intended failures: stale health core overwrite, failed migration gate handling, migration await timeout, and one-shot Just Lift defaults loading. +- The same final review set passed 21/21 after the fixes. + +## Final verification + +All commands used Android Studio's JBR and `-Pskip.supabase.check=true`. + +- Required focused consumer suite: **128 tests, 0 failures, 0 errors**. +- Required DWSM/regression suite: **173 tests, 0 failures, 0 errors**. +- `:shared:compileAndroidMain` plus `KoinModuleVerifyTest`: **build passed; 1 test, 0 failures, 0 errors**. +- Unfiltered `:shared:testAndroidHostTest`: **2516 tests, 0 failures, 0 errors**. +- `:shared:compileKotlinIosArm64`: **build passed**. +- Independent full-diff review: **no remaining Critical or Important findings**. +- Static boundary audit: no common-main profile-owned calls through `PreferencesManager`, no common-main legacy quick-start calls, and production DI binds `ProfileEquipmentRackRepository` only. +- `git diff --check`: passed (Git reports only the repository's Windows line-ending notices). + +## Notes + +- Existing project compiler/deprecation warnings remain; verification introduced no build or test failures. +- `openspec/AGENTS.md` referenced by the root instructions is absent in this checkout; the complete Task 5 brief was used as the authoritative implementation contract. diff --git a/.superpowers/sdd/data-task-6-report.md b/.superpowers/sdd/data-task-6-report.md new file mode 100644 index 000000000..686745258 --- /dev/null +++ b/.superpowers/sdd/data-task-6-report.md @@ -0,0 +1,41 @@ +# Data Task 6 implementation report + +Base: `2103af00` +Commit subject: `feat: gate live VBT by active profile` + +## Delivered behavior + +- Preserved unconditional real biomechanics capture and extracted the existing live decision into `ActiveSessionEngine.evaluateLatestVbtResult()` with the working-rep guard inside that production seam. +- Added one immutable `VbtRuntimeSettings(enabled, velocityLossThresholdPercent, autoEndOnVelocityLoss)` `StateFlow` in `WorkoutCoordinator`; live evaluation reads it once per completed rep. +- Initialized and updated the complete VBT triple from `SettingsManager.userPreferences`, using `map` plus `distinctUntilChanged`, rethrowing collector cancellation, and adding no new DI binding. +- Disabled VBT now suppresses only velocity-loss interpretation, threshold/verbal events, and auto-end. Raw rep results, set summaries, persistence, MCV/peak values, PR/progression inputs, and history remain available. +- Re-enabling preserves the configured threshold and auto-end choice and restores the prior alert/auto-end behavior without resetting the biomechanics baseline. +- Replaced the test-local verbal tracker with production policy `UserPreferences.verbalEncouragementEventOrNull()`. Local adult confirmation neutralizes vulgar and dominatrix routing while leaving persisted profile intent unchanged. +- Threaded `vbtEnabled` through `WorkoutUiState`, the `ActiveWorkoutScreen` remember key and value, both `WorkoutTab` layers, `WorkoutHud`, and `StatsPage`. +- Disabled UI keeps raw MCV and Peak visible in a neutral color while hiding Zone, velocity-loss, and estimated-reps-left interpretation. `BiomechanicsHistoryCard` remains ungated. + +## TDD evidence + +1. Baseline characterization: the original focused VBT/event/integration matrix passed (86 tests, zero failures). +2. Behavior-neutral seam extraction: the same matrix passed again (86 tests, zero failures). +3. Feature RED: + - The first production-backed disabled test failed because live threshold processing still auto-ended and reset the real `BiomechanicsEngine`; the XML failure was `latestRepResult` unexpectedly null, with logs showing `VELOCITY_THRESHOLD_REACHED` and auto-end after two threshold reps. + - The completed test set then failed to compile on the deliberately absent `VbtRuntimeSettings`, coherent snapshot flow, and production verbal-policy API. + - After runtime GREEN, the two UI source-contract tests remained RED until the full Compose chain was threaded and the interpretation branches were gated. +4. GREEN: the final focused matrix contains 92 tests across 9 suites with zero failures or errors. `VbtUiWiringTest` contributes three tests, including a malformed-source proof that the gate-slicing assertions reject raw metrics moved inside a VBT gate or estimated reps moved outside the loss gate. + +Runtime tests use `DWSMTestHarness`, its Ready fake profile, the real `BiomechanicsEngine`, real `WorkoutState`, real `HapticEvent` stream, and `FakeBiomechanicsRepository`. Source contracts use `readProjectFile` and verify all required Compose hops plus the absence of a history gate. + +## Verification + +- `:shared:testAndroidHostTest --tests "*Vbt*" --tests "*VerbalEncouragementGateTest*" --tests "*WorkoutCoordinatorEventTest*" --tests "*ActiveSessionEngineIntegrationTest*"`: PASS, 92/92. +- `:shared:compileKotlinIosArm64`: PASS. +- Android main and Android host test compilation: PASS as part of the focused matrix. +- `git diff --check`: PASS (Git only reports the repository's LF-to-CRLF checkout notices). + +The build continues to print existing project deprecation/performance warnings; this task introduced no new compilation failure or Koin graph change. + +## Adaptations to the real codebase + +- No extra harness API was required: `DWSMTestHarness` already exposed the active engine, coordinator, Ready repository, real settings facade, and fake biomechanics persistence repository needed by the production-backed tests. +- Existing `WorkoutCoordinatorEventTest` and `ActiveSessionEngineIntegrationTest` already covered event coexistence and subordinate VBT configuration. The new runtime test adds the missing master-toggle/coherent-switch/persistence/adult-policy assertions without duplicating those fixtures. diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 60654917c..5b9d3d893 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -98,6 +98,131 @@ abstract class VerifyReleaseCueResourcesTask : DefaultTask() { } } +abstract class VerifyQaReleaseBoundaryTask : DefaultTask() { + @get:InputFile + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val forbiddenMarkersFile: RegularFileProperty + + @get:Internal + abstract val releaseApkDir: DirectoryProperty + + @get:Internal + abstract val releaseIntermediatesDir: DirectoryProperty + + @get:Internal + abstract val projectRootDir: DirectoryProperty + + @TaskAction + fun verifyReleaseBoundary() { + fun ByteArray.containsSequence(sequence: ByteArray): Boolean { + if (sequence.isEmpty()) return true + if (sequence.size > size) return false + + for (startIndex in 0..size - sequence.size) { + var matches = true + for (offset in sequence.indices) { + if (this[startIndex + offset] != sequence[offset]) { + matches = false + break + } + } + if (matches) return true + } + return false + } + + val projectRoot = projectRootDir.get().asFile + val markerFile = forbiddenMarkersFile.get().asFile + val forbiddenMarkers = markerFile.readLines() + .map(String::trim) + .filter(String::isNotEmpty) + + if (forbiddenMarkers.isEmpty()) { + throw GradleException("QA release marker inventory is empty: ${markerFile.relativeTo(projectRoot)}") + } + if (forbiddenMarkers.distinct().size != forbiddenMarkers.size) { + throw GradleException("QA release marker inventory contains duplicates: ${markerFile.relativeTo(projectRoot)}") + } + + val markerBytes = forbiddenMarkers.associateWith { it.toByteArray(Charsets.UTF_8) } + val releaseArtifacts = releaseApkDir.get().asFile + .takeIf(File::isDirectory) + ?.walkTopDown() + ?.filter { it.isFile && it.extension.equals("apk", ignoreCase = true) } + ?.sortedBy { it.invariantSeparatorsPath } + ?.toList() + .orEmpty() + + if (releaseArtifacts.isEmpty()) { + throw GradleException("No Android release APK artifacts found. Run :androidApp:assembleRelease first.") + } + + val releaseIntermediates = releaseIntermediatesDir.get().asFile + val releaseManifests = listOf( + "merged_manifest", + "merged_manifests", + "packaged_manifests", + ).flatMap { relativeRoot -> + File(releaseIntermediates, relativeRoot) + .takeIf(File::isDirectory) + ?.walkTopDown() + ?.filter { file -> + file.isFile && + file.name == "AndroidManifest.xml" && + file.relativeTo(releaseIntermediates).invariantSeparatorsPath + .contains("/release/", ignoreCase = true) + } + ?.toList() + .orEmpty() + }.distinct().sortedBy { it.invariantSeparatorsPath } + + if (releaseManifests.isEmpty()) { + throw GradleException( + "No merged or packaged Android release manifests found after :androidApp:assembleRelease.", + ) + } + + val leaks = mutableListOf() + releaseManifests.forEach { manifest -> + val content = manifest.readBytes() + markerBytes.forEach { (marker, bytes) -> + if (content.containsSequence(bytes)) { + leaks += "${manifest.relativeTo(projectRoot).invariantSeparatorsPath}: $marker" + } + } + } + + var scannedEntries = 0 + releaseArtifacts.forEach { artifact -> + ZipFile(artifact).use { zip -> + zip.entries().asSequence() + .filterNot { it.isDirectory } + .forEach { entry -> + scannedEntries += 1 + val content = zip.getInputStream(entry).use { it.readBytes() } + markerBytes.forEach { (marker, bytes) -> + if (content.containsSequence(bytes)) { + leaks += + "${artifact.relativeTo(projectRoot).invariantSeparatorsPath}!/${entry.name}: $marker" + } + } + } + } + } + + if (leaks.isNotEmpty()) { + throw GradleException( + "Android release outputs contain debug-only QA markers:\n${leaks.joinToString("\n") { " - $it" }}", + ) + } + + println( + "Verified ${forbiddenMarkers.size} QA markers absent from ${releaseManifests.size} release manifests " + + "and $scannedEntries entries across ${releaseArtifacts.size} release APK(s).", + ) + } +} + abstract class VerifySupabaseRuntimeConfigTask : DefaultTask() { @get:Input abstract val configuredSupabaseUrl: Property @@ -196,7 +321,7 @@ android { targetSdk = 37 // Fail fast if CI injects an invalid version code instead of silently shipping a default. versionCode = injectedVersionCode ?: 5 - versionName = "0.9.5" + versionName = "0.9.6" // Supabase config injected from local.properties buildConfigField("String", "SUPABASE_URL", "\"$supabaseUrl\"") @@ -285,6 +410,18 @@ tasks.register("verifyReleaseCueResources") { projectRootDir.set(rootProject.layout.projectDirectory) } +tasks.register("verifyQaReleaseBoundary") { + group = "verification" + description = "Assembles and rejects Android release outputs containing debug-only Profile QA markers." + + dependsOn("assembleRelease") + + forbiddenMarkersFile.set(layout.projectDirectory.file("config/qa-release-forbidden-markers.txt")) + releaseApkDir.set(layout.buildDirectory.dir("outputs/apk/release")) + releaseIntermediatesDir.set(layout.buildDirectory.dir("intermediates")) + projectRootDir.set(rootProject.layout.projectDirectory) +} + dependencies { // Shared module implementation(project(":shared")) @@ -332,4 +469,7 @@ dependencies { testImplementation(libs.truth) testImplementation(libs.koin.test) testImplementation(libs.koin.test.junit4) + testImplementation(libs.ktor.client.mock) + testImplementation(libs.multiplatform.settings) + testImplementation(libs.multiplatform.settings.test) } diff --git a/androidApp/config/qa-release-forbidden-markers.txt b/androidApp/config/qa-release-forbidden-markers.txt new file mode 100644 index 000000000..40a45cc94 --- /dev/null +++ b/androidApp/config/qa-release-forbidden-markers.txt @@ -0,0 +1,7 @@ +ProfileQa +QaBlockingPortalApiClient +QA_SEED_PROFILE +[QA] Profile +QA fixture is local-only +profile_qa_fixture_gate +com.devil.phoenixproject.qa diff --git a/androidApp/src/debug/AndroidManifest.xml b/androidApp/src/debug/AndroidManifest.xml new file mode 100644 index 000000000..c39c16e15 --- /dev/null +++ b/androidApp/src/debug/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + diff --git a/androidApp/src/debug/kotlin/com/devil/phoenixproject/qa/ProfileQaDebugApp.kt b/androidApp/src/debug/kotlin/com/devil/phoenixproject/qa/ProfileQaDebugApp.kt new file mode 100644 index 000000000..9825b7c13 --- /dev/null +++ b/androidApp/src/debug/kotlin/com/devil/phoenixproject/qa/ProfileQaDebugApp.kt @@ -0,0 +1,47 @@ +package com.devil.phoenixproject.qa + +import com.devil.phoenixproject.VitruvianApp +import com.devil.phoenixproject.data.repository.AssessmentRepository +import com.devil.phoenixproject.data.repository.ExerciseRepository +import com.devil.phoenixproject.data.repository.PersonalRecordRepository +import com.devil.phoenixproject.data.repository.RepMetricRepository +import com.devil.phoenixproject.data.repository.UserProfileRepository +import com.devil.phoenixproject.data.repository.VelocityOneRepMaxRepository +import com.devil.phoenixproject.data.repository.WorkoutRepository +import com.devil.phoenixproject.data.sync.PortalApiClient +import com.devil.phoenixproject.data.sync.PortalTokenStorage +import com.devil.phoenixproject.data.sync.SupabaseConfig +import com.devil.phoenixproject.database.VitruvianDatabase +import org.koin.core.context.loadKoinModules +import org.koin.dsl.module + +class ProfileQaDebugApp : VitruvianApp() { + override fun onCreate() { + super.onCreate() + + loadKoinModules( + module { + single { ProfileQaFixtureGate(this@ProfileQaDebugApp) } + single { + ProfileQaSeeder( + userProfileRepository = get(), + exerciseRepository = get(), + workoutRepository = get(), + repMetricRepository = get(), + personalRecordRepository = get(), + assessmentRepository = get(), + velocityOneRepMaxRepository = get(), + database = get(), + ) + } + single { + QaBlockingPortalApiClient( + supabaseConfig = get(), + tokenStorage = get(), + fixtureGate = get(), + ) + } + }, + ) + } +} diff --git a/androidApp/src/debug/kotlin/com/devil/phoenixproject/qa/ProfileQaFixtureGate.kt b/androidApp/src/debug/kotlin/com/devil/phoenixproject/qa/ProfileQaFixtureGate.kt new file mode 100644 index 000000000..12b752602 --- /dev/null +++ b/androidApp/src/debug/kotlin/com/devil/phoenixproject/qa/ProfileQaFixtureGate.kt @@ -0,0 +1,20 @@ +package com.devil.phoenixproject.qa + +import android.content.Context + +class ProfileQaFixtureGate(context: Context) { + private val preferences = context.getSharedPreferences(PREFERENCES_FILE, Context.MODE_PRIVATE) + + fun isEnabled(): Boolean = preferences.getBoolean(KEY_ENABLED, false) + + fun enable() { + check(preferences.edit().putBoolean(KEY_ENABLED, true).commit()) { + "Could not persist the profile QA fixture gate" + } + } + + private companion object { + const val PREFERENCES_FILE = "profile_qa_fixture_gate" + const val KEY_ENABLED = "enabled" + } +} diff --git a/androidApp/src/debug/kotlin/com/devil/phoenixproject/qa/ProfileQaSeedReceiver.kt b/androidApp/src/debug/kotlin/com/devil/phoenixproject/qa/ProfileQaSeedReceiver.kt new file mode 100644 index 000000000..d18c7ec3a --- /dev/null +++ b/androidApp/src/debug/kotlin/com/devil/phoenixproject/qa/ProfileQaSeedReceiver.kt @@ -0,0 +1,79 @@ +package com.devil.phoenixproject.qa + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import org.koin.core.context.GlobalContext + +interface ProfileQaSeedPendingResult { + fun finish() +} + +class ProfileQaSeedReceiver( + private val enableGate: (Context) -> Unit = { ProfileQaFixtureGate(it).enable() }, + private val seed: suspend () -> ProfileQaSeedResult = { + GlobalContext.get().get().seed() + }, + private val beginAsync: ProfileQaSeedReceiver.() -> ProfileQaSeedPendingResult = { + AndroidPendingResult(goAsync()) + }, + private val launch: ((suspend () -> Unit) -> Unit) = { work -> + CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { work() } + }, + private val log: (String) -> Unit = { Log.i(LOG_TAG, it) }, +) : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != ACTION_SEED_PROFILE) return + + try { + enableGate(context) + } catch (failure: Throwable) { + log(failureMarker(failure)) + return + } + + val pending = try { + beginAsync() + } catch (failure: Throwable) { + log(failureMarker(failure)) + return + } + + try { + launch { + try { + seed() + log(ProfileQaSeeder.RESULT_OK) + } catch (failure: Throwable) { + log(failureMarker(failure)) + } finally { + pending.finish() + } + } + } catch (failure: Throwable) { + log(failureMarker(failure)) + pending.finish() + } + } + + private fun failureMarker(failure: Throwable): String = + "PROFILE_QA_SEED_FAILED:${failure::class.simpleName ?: "Unknown"}" + + private class AndroidPendingResult( + private val pendingResult: PendingResult, + ) : ProfileQaSeedPendingResult { + override fun finish() { + pendingResult.finish() + } + } + + companion object { + const val ACTION_SEED_PROFILE = "com.devil.phoenixproject.QA_SEED_PROFILE" + private const val LOG_TAG = "ProfileQaSeed" + } +} diff --git a/androidApp/src/debug/kotlin/com/devil/phoenixproject/qa/ProfileQaSeeder.kt b/androidApp/src/debug/kotlin/com/devil/phoenixproject/qa/ProfileQaSeeder.kt new file mode 100644 index 000000000..5b522b5d0 --- /dev/null +++ b/androidApp/src/debug/kotlin/com/devil/phoenixproject/qa/ProfileQaSeeder.kt @@ -0,0 +1,543 @@ +package com.devil.phoenixproject.qa + +import com.devil.phoenixproject.data.repository.AssessmentRepository +import com.devil.phoenixproject.data.repository.ExerciseRepository +import com.devil.phoenixproject.data.repository.PersonalRecordRepository +import com.devil.phoenixproject.data.repository.RepMetricRepository +import com.devil.phoenixproject.data.repository.UserProfile +import com.devil.phoenixproject.data.repository.UserProfileRepository +import com.devil.phoenixproject.data.repository.VelocityOneRepMaxRepository +import com.devil.phoenixproject.data.repository.WorkoutRepository +import com.devil.phoenixproject.database.VitruvianDatabase +import com.devil.phoenixproject.domain.model.CoreProfilePreferences +import com.devil.phoenixproject.domain.model.JustLiftDefaultsDocument +import com.devil.phoenixproject.domain.model.LedPreferences +import com.devil.phoenixproject.domain.model.ProfileLocalSafetyPreferences +import com.devil.phoenixproject.domain.model.RackItem +import com.devil.phoenixproject.domain.model.RackItemBehavior +import com.devil.phoenixproject.domain.model.RackItemCategory +import com.devil.phoenixproject.domain.model.RackPreferences +import com.devil.phoenixproject.domain.model.RepCountTiming +import com.devil.phoenixproject.domain.model.RepMetricData +import com.devil.phoenixproject.domain.model.ScalingBasis +import com.devil.phoenixproject.domain.model.SingleExerciseDefaultsDocument +import com.devil.phoenixproject.domain.model.VbtPreferences +import com.devil.phoenixproject.domain.model.VulgarTier +import com.devil.phoenixproject.domain.model.WeightUnit +import com.devil.phoenixproject.domain.model.WorkoutPreferences +import com.devil.phoenixproject.domain.model.WorkoutSession +import com.devil.phoenixproject.domain.onerepmax.VelocityOneRepMaxResult +import com.devil.phoenixproject.util.encodeRackItemsJson +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +data class ProfileQaSeedResult( + val marker: String, + val profileAId: String, + val profileBId: String, + val sessionCount: Int, +) + +interface ProfileQaFixtureRowCleanup { + fun deletePersonalRecord(id: Long) + fun deleteVelocityOneRepMax(id: Long) +} + +private class DatabaseProfileQaFixtureRowCleanup( + database: VitruvianDatabase, +) : ProfileQaFixtureRowCleanup { + private val queries = database.vitruvianDatabaseQueries + + override fun deletePersonalRecord(id: Long) { + queries.deletePersonalRecordById(id) + } + + override fun deleteVelocityOneRepMax(id: Long) { + queries.deleteVelocityOneRepMaxById(id) + } +} + +class ProfileQaSeeder( + private val userProfileRepository: UserProfileRepository, + private val exerciseRepository: ExerciseRepository, + private val workoutRepository: WorkoutRepository, + private val repMetricRepository: RepMetricRepository, + private val personalRecordRepository: PersonalRecordRepository, + private val assessmentRepository: AssessmentRepository, + private val velocityOneRepMaxRepository: VelocityOneRepMaxRepository, + database: VitruvianDatabase, + private val fixtureRowCleanup: ProfileQaFixtureRowCleanup = + DatabaseProfileQaFixtureRowCleanup(database), +) { + private val seedMutex = Mutex() + + suspend fun seed(): ProfileQaSeedResult = seedMutex.withLock { + val exercise = requireNotNull(exerciseRepository.findByName(EXERCISE_NAME)) { + "Catalog exercise missing: $EXERCISE_NAME" + } + val exerciseId = requireNotNull(exercise.id) { + "Catalog exercise has no stable ID: $EXERCISE_NAME" + } + val profileA = findOrCreateProfile(PROFILE_A_NAME, PROFILE_A_COLOR) + val profileB = findOrCreateProfile(PROFILE_B_NAME, PROFILE_B_COLOR) + + seedProfile( + profileA, + exerciseId, + profileKey = "a", + originalCatalogOneRepMaxKg = exercise.oneRepMaxKg, + ) + seedProfile( + profileB, + exerciseId, + profileKey = "b", + originalCatalogOneRepMaxKg = exercise.oneRepMaxKg, + ) + userProfileRepository.setActiveProfile(profileA.id) + + return ProfileQaSeedResult( + marker = RESULT_OK, + profileAId = profileA.id, + profileBId = profileB.id, + sessionCount = SESSION_TIMESTAMPS.size * 2, + ) + } + + private suspend fun findOrCreateProfile(name: String, colorIndex: Int): UserProfile { + val existing = userProfileRepository.allProfiles.value.firstOrNull { it.name == name } + return existing ?: userProfileRepository.createProfile(name, colorIndex) + } + + private suspend fun seedProfile( + profile: UserProfile, + exerciseId: String, + profileKey: String, + originalCatalogOneRepMaxKg: Float?, + ) { + val isProfileA = profileKey == "a" + val rackItemId = rackItemId(profileKey) + userProfileRepository.setActiveProfile(profile.id) + userProfileRepository.updateProfile( + id = profile.id, + name = if (isProfileA) PROFILE_A_NAME else PROFILE_B_NAME, + colorIndex = if (isProfileA) PROFILE_A_COLOR else PROFILE_B_COLOR, + ) + userProfileRepository.updateCore(profile.id, corePreferences(isProfileA)) + userProfileRepository.updateRack(profile.id, rackPreferences(profileKey, isProfileA)) + userProfileRepository.updateWorkout( + profile.id, + workoutPreferences(exerciseId, rackItemId, isProfileA), + ) + userProfileRepository.updateLed(profile.id, ledPreferences(isProfileA)) + userProfileRepository.updateLocalSafety(profile.id, localSafetyPreferences(isProfileA)) + userProfileRepository.updateVbt(profile.id, vbtPreferences(isProfileA)) + + cleanupFixtureRows(profile.id, exerciseId, profileKey) + seedSessions(profile.id, exerciseId, profileKey, rackItemId, isProfileA) + seedPersonalRecords(profile.id, exerciseId, originalCatalogOneRepMaxKg) + assessmentRepository.saveAssessment( + exerciseId = exerciseId, + estimatedOneRepMaxKg = ASSESSMENT_TOTAL_KG, + loadVelocityDataJson = ASSESSMENT_DATA_JSON, + sessionId = null, + userOverrideKg = null, + profileId = profile.id, + ) + velocityOneRepMaxRepository.insert( + result = VelocityOneRepMaxResult( + estimatedPerCableKg = VELOCITY_ONE_REP_MAX_PER_CABLE_KG, + mvtUsedMs = VELOCITY_MVT_MS, + r2 = VELOCITY_R2, + distinctLoads = VELOCITY_DISTINCT_LOADS, + passedQualityGate = true, + ), + exerciseId = exerciseId, + computedAt = VELOCITY_COMPUTED_AT, + profileId = profile.id, + ) + } + + private suspend fun cleanupFixtureRows(profileId: String, exerciseId: String, profileKey: String) { + sessionIds(profileKey).forEach { sessionId -> + repMetricRepository.deleteRepMetrics(sessionId) + workoutRepository.deleteSession(sessionId) + } + personalRecordRepository.getAllPRsForExercise(exerciseId, profileId) + .filter { it.workoutMode == WORKOUT_MODE } + .forEach { fixtureRowCleanup.deletePersonalRecord(it.id) } + assessmentRepository.getAssessmentsByExercise(exerciseId, profileId).first() + .filter { + it.assessmentSessionId == null && + it.estimatedOneRepMaxKg == ASSESSMENT_TOTAL_KG && + it.loadVelocityData == ASSESSMENT_DATA_JSON + } + .forEach { assessmentRepository.deleteAssessment(it.id) } + velocityOneRepMaxRepository.getHistory(exerciseId, profileId).first() + .filter { + it.computedAt == VELOCITY_COMPUTED_AT && + it.estimatedPerCableKg == VELOCITY_ONE_REP_MAX_PER_CABLE_KG && + it.mvtUsedMs == VELOCITY_MVT_MS && + it.r2 == VELOCITY_R2 && + it.distinctLoads == VELOCITY_DISTINCT_LOADS && + it.passedQualityGate + } + .forEach { fixtureRowCleanup.deleteVelocityOneRepMax(it.id) } + } + + private suspend fun seedSessions( + profileId: String, + exerciseId: String, + profileKey: String, + rackItemId: String, + isProfileA: Boolean, + ) { + SESSION_TIMESTAMPS.indices.forEach { index -> + val session = workoutSession( + profileId = profileId, + exerciseId = exerciseId, + profileKey = profileKey, + index = index, + isProfileA = isProfileA, + ) + workoutRepository.saveSession(session) + repMetricRepository.saveRepMetrics( + session.id, + (1..session.workingReps).map { rep -> repMetric(session, rep) }, + ) + } + } + + private suspend fun seedPersonalRecords( + profileId: String, + exerciseId: String, + originalCatalogOneRepMaxKg: Float?, + ) { + try { + personalRecordRepository.updatePRsIfBetter( + exerciseId = exerciseId, + weightPRWeightPerCableKg = 50f, + volumePRWeightPerCableKg = 50f, + reps = 3, + workoutMode = WORKOUT_MODE, + timestamp = SESSION_TIMESTAMPS.last(), + profileId = profileId, + cableCount = 2, + ).getOrThrow() + personalRecordRepository.updatePRsIfBetter( + exerciseId = exerciseId, + weightPRWeightPerCableKg = 40f, + volumePRWeightPerCableKg = 40f, + reps = 12, + workoutMode = WORKOUT_MODE, + timestamp = SESSION_TIMESTAMPS[2], + profileId = profileId, + cableCount = 2, + ).getOrThrow() + } finally { + exerciseRepository.updateOneRepMax(exerciseId, originalCatalogOneRepMaxKg) + } + } + + private fun workoutSession( + profileId: String, + exerciseId: String, + profileKey: String, + index: Int, + isProfileA: Boolean, + ): WorkoutSession { + val load = SESSION_LOADS[index] + val reps = SESSION_REPS[index] + val timestamp = SESSION_TIMESTAMPS[index] + return WorkoutSession( + id = sessionIds(profileKey)[index], + timestamp = timestamp, + mode = WORKOUT_MODE, + reps = reps, + weightPerCableKg = load, + duration = 30_000L + index * 1_000L, + totalReps = reps, + warmupReps = 0, + workingReps = reps, + stopAtTop = isProfileA, + exerciseId = exerciseId, + exerciseName = EXERCISE_NAME, + routineSessionId = "profile-qa-$profileKey-routine-session", + routineName = if (isProfileA) "[QA] Strength A" else "[QA] Strength B", + peakForceConcentricA = load + 6f, + peakForceConcentricB = load + 5f, + peakForceEccentricA = load + 4f, + peakForceEccentricB = load + 3f, + avgForceConcentricA = load + 2f, + avgForceConcentricB = load + 1f, + avgForceEccentricA = load, + avgForceEccentricB = load - 1f, + heaviestLiftKg = load, + totalVolumeKg = load * reps * 2f, + cableCount = 2, + displayMultiplier = 2, + externalAddedLoadKg = if (isProfileA) 10f else 0f, + counterweightKg = if (isProfileA) 0f else 7.5f, + rackItemsJson = rackSnapshotJson(profileKey, isProfileA), + estimatedCalories = 5f + index, + workingAvgWeightKg = load, + peakWeightKg = load, + avgMcvMmS = SESSION_MCV_MM_S[index], + avgAsymmetryPercent = if (isProfileA) 2f else 8f, + totalVelocityLossPercent = 5f + index, + dominantSide = if (isProfileA) "BALANCED" else "RIGHT", + strengthProfile = if (isProfileA) "STRENGTH" else "ENDURANCE", + profileId = profileId, + ) + } + + private fun repMetric(session: WorkoutSession, repNumber: Int): RepMetricData { + val start = session.timestamp + (repNumber - 1) * 1_000L + val load = session.weightPerCableKg + return RepMetricData( + repNumber = repNumber, + isWarmup = false, + startTimestamp = start, + endTimestamp = start + 900L, + durationMs = 900L, + concentricDurationMs = 400L, + concentricPositions = floatArrayOf(0f, 250f, 500f), + concentricLoadsA = floatArrayOf(load, load + 1f, load + 2f), + concentricLoadsB = floatArrayOf(load, load + 0.5f, load + 1.5f), + concentricVelocities = floatArrayOf(400f, 600f, 500f), + concentricTimestamps = longArrayOf(0L, 200L, 400L), + eccentricDurationMs = 500L, + eccentricPositions = floatArrayOf(500f, 250f, 0f), + eccentricLoadsA = floatArrayOf(load + 2f, load + 1f, load), + eccentricLoadsB = floatArrayOf(load + 1.5f, load + 0.5f, load), + eccentricVelocities = floatArrayOf(-300f, -450f, -350f), + eccentricTimestamps = longArrayOf(400L, 650L, 900L), + peakForceA = load + 6f, + peakForceB = load + 5f, + avgForceConcentricA = load + 2f, + avgForceConcentricB = load + 1f, + avgForceEccentricA = load + 1f, + avgForceEccentricB = load, + peakVelocity = 600f, + avgVelocityConcentric = 500f, + avgVelocityEccentric = -375f, + rangeOfMotionMm = 500f, + peakPowerWatts = 300f + load, + avgPowerWatts = 220f + load, + ) + } + + private fun corePreferences(isProfileA: Boolean) = if (isProfileA) { + CoreProfilePreferences(bodyWeightKg = 82.5f, weightUnit = WeightUnit.KG, weightIncrement = 2.5f) + } else { + CoreProfilePreferences(bodyWeightKg = 165f, weightUnit = WeightUnit.LB, weightIncrement = 5f) + } + + private fun rackPreferences(profileKey: String, isProfileA: Boolean): RackPreferences { + val createdAt = if (isProfileA) 1_700_000_001_000L else 1_700_000_002_000L + return RackPreferences( + items = listOf( + RackItem( + id = rackItemId(profileKey), + name = if (isProfileA) "QA Weighted Vest A" else "QA Assistance B", + category = if (isProfileA) RackItemCategory.WEIGHTED_VEST else RackItemCategory.ASSISTANCE, + weightKg = if (isProfileA) 10f else 7.5f, + behavior = if (isProfileA) RackItemBehavior.ADDED_RESISTANCE else RackItemBehavior.COUNTERWEIGHT, + enabled = true, + sortOrder = if (isProfileA) 1 else 2, + createdAt = createdAt, + updatedAt = createdAt + 100L, + ), + ), + ) + } + + private fun workoutPreferences( + exerciseId: String, + rackItemId: String, + isProfileA: Boolean, + ): WorkoutPreferences = if (isProfileA) { + WorkoutPreferences( + stopAtTop = true, + beepsEnabled = true, + stallDetectionEnabled = true, + audioRepCountEnabled = true, + repCountTiming = RepCountTiming.TOP, + summaryCountdownSeconds = 15, + autoStartCountdownSeconds = 7, + gamificationEnabled = true, + autoStartRoutine = true, + countdownBeepsEnabled = true, + repSoundEnabled = true, + motionStartEnabled = true, + weightSuggestionsEnabled = true, + defaultRoutineExerciseUsePercentOfPR = true, + defaultRoutineExerciseWeightPercentOfPR = 75, + voiceStopEnabled = true, + justLiftDefaults = JustLiftDefaultsDocument( + workoutModeId = 0, + weightPerCableKg = 25f, + weightChangePerRep = 2.5f, + eccentricLoadPercentage = 125, + echoLevelValue = 3, + stallDetectionEnabled = true, + repCountTimingName = RepCountTiming.TOP.name, + restSeconds = 45, + ), + singleExerciseDefaults = mapOf( + exerciseId to SingleExerciseDefaultsDocument( + exerciseId = exerciseId, + setReps = listOf(8, 10, 12), + weightPerCableKg = 40f, + setWeightsPerCableKg = listOf(35f, 40f, 45f), + progressionKg = 2.5f, + setRestSeconds = listOf(45, 45, 60), + workoutModeId = 0, + eccentricLoadPercentage = 125, + echoLevelValue = 3, + duration = 30, + isAMRAP = true, + perSetRestTime = true, + defaultRackItemIds = listOf(rackItemId), + ), + ), + ) + } else { + WorkoutPreferences( + stopAtTop = false, + beepsEnabled = false, + stallDetectionEnabled = false, + audioRepCountEnabled = false, + repCountTiming = RepCountTiming.BOTTOM, + summaryCountdownSeconds = 5, + autoStartCountdownSeconds = 3, + gamificationEnabled = false, + autoStartRoutine = false, + countdownBeepsEnabled = false, + repSoundEnabled = false, + motionStartEnabled = false, + weightSuggestionsEnabled = false, + defaultRoutineExerciseUsePercentOfPR = false, + defaultRoutineExerciseWeightPercentOfPR = 60, + voiceStopEnabled = false, + justLiftDefaults = JustLiftDefaultsDocument( + workoutModeId = 10, + weightPerCableKg = 15f, + weightChangePerRep = -0.5f, + eccentricLoadPercentage = 75, + echoLevelValue = 0, + stallDetectionEnabled = false, + repCountTimingName = RepCountTiming.BOTTOM.name, + restSeconds = 90, + ), + singleExerciseDefaults = mapOf( + exerciseId to SingleExerciseDefaultsDocument( + exerciseId = exerciseId, + setReps = listOf(12, 10, 8), + weightPerCableKg = 30f, + setWeightsPerCableKg = listOf(30f, 27.5f, 25f), + progressionKg = -2.5f, + setRestSeconds = listOf(90, 75, 60), + workoutModeId = 10, + eccentricLoadPercentage = 75, + echoLevelValue = 0, + duration = 45, + isAMRAP = false, + perSetRestTime = false, + defaultRackItemIds = listOf(rackItemId), + ), + ), + ) + } + + private fun ledPreferences(isProfileA: Boolean) = if (isProfileA) { + LedPreferences(colorScheme = 1, discoModeUnlocked = false) + } else { + LedPreferences(colorScheme = 4, discoModeUnlocked = true) + } + + private fun vbtPreferences(isProfileA: Boolean) = if (isProfileA) { + VbtPreferences( + enabled = true, + velocityLossThresholdPercent = 20, + autoEndOnVelocityLoss = true, + defaultScalingBasis = ScalingBasis.ESTIMATED_1RM, + verbalEncouragementEnabled = true, + vulgarModeEnabled = true, + vulgarTier = VulgarTier.MIX, + dominatrixModeUnlocked = true, + dominatrixModeActive = true, + ) + } else { + VbtPreferences( + enabled = false, + velocityLossThresholdPercent = 35, + autoEndOnVelocityLoss = false, + defaultScalingBasis = ScalingBasis.MAX_VOLUME_PR, + verbalEncouragementEnabled = false, + vulgarModeEnabled = false, + vulgarTier = VulgarTier.MILD, + dominatrixModeUnlocked = false, + dominatrixModeActive = false, + ) + } + + private fun localSafetyPreferences(isProfileA: Boolean) = if (isProfileA) { + ProfileLocalSafetyPreferences( + safeWord = "Phoenix Alpha", + safeWordCalibrated = true, + adultsOnlyConfirmed = true, + adultsOnlyPrompted = true, + ) + } else { + ProfileLocalSafetyPreferences( + safeWord = "Phoenix Bravo", + safeWordCalibrated = false, + adultsOnlyConfirmed = false, + adultsOnlyPrompted = false, + ) + } + + private fun rackItemId(profileKey: String) = if (profileKey == "a") { + "profile-qa-a-rack-vest" + } else { + "profile-qa-b-rack-assist" + } + + private fun rackSnapshotJson(profileKey: String, isProfileA: Boolean): String { + val rackItem = rackPreferences(profileKey, isProfileA).items.single() + return encodeRackItemsJson(listOf(rackItem)) + } + + private fun sessionIds(profileKey: String) = (1..SESSION_TIMESTAMPS.size).map { + "profile-qa-$profileKey-session-$it" + } + + companion object { + const val RESULT_OK = "PROFILE_QA_SEED_OK" + const val PROFILE_A_NAME = "[QA] Profile A" + const val PROFILE_B_NAME = "[QA] Profile B" + private const val PROFILE_A_COLOR = 2 + private const val PROFILE_B_COLOR = 7 + private const val EXERCISE_NAME = "Bench Press" + private const val WORKOUT_MODE = "QA Fixture" + private const val ASSESSMENT_TOTAL_KG = 120f + private const val ASSESSMENT_DATA_JSON = + "[{\"loadKg\":\"80.0\",\"velocityMs\":\"0.75\"},{\"loadKg\":\"100.0\",\"velocityMs\":\"0.55\"},{\"loadKg\":\"120.0\",\"velocityMs\":\"0.40\"}]" + private const val VELOCITY_ONE_REP_MAX_PER_CABLE_KG = 77f + private const val VELOCITY_MVT_MS = 0.30f + private const val VELOCITY_R2 = 0.97f + private const val VELOCITY_DISTINCT_LOADS = 3 + private const val VELOCITY_COMPUTED_AT = 4_102_444_800_000L + private val SESSION_TIMESTAMPS = listOf( + 1_700_100_000_000L, + 1_700_200_000_000L, + 1_700_300_000_000L, + 1_700_400_000_000L, + 1_700_500_000_000L, + ) + private val SESSION_LOADS = listOf(30f, 35f, 40f, 45f, 50f) + private val SESSION_REPS = listOf(10, 8, 12, 5, 3) + private val SESSION_MCV_MM_S = listOf(750f, 680f, 600f, 520f, 450f) + } +} diff --git a/androidApp/src/debug/kotlin/com/devil/phoenixproject/qa/QaBlockingPortalApiClient.kt b/androidApp/src/debug/kotlin/com/devil/phoenixproject/qa/QaBlockingPortalApiClient.kt new file mode 100644 index 000000000..c423f199c --- /dev/null +++ b/androidApp/src/debug/kotlin/com/devil/phoenixproject/qa/QaBlockingPortalApiClient.kt @@ -0,0 +1,41 @@ +package com.devil.phoenixproject.qa + +import com.devil.phoenixproject.data.sync.KnownEntityIds +import com.devil.phoenixproject.data.sync.PortalApiClient +import com.devil.phoenixproject.data.sync.PortalApiException +import com.devil.phoenixproject.data.sync.PortalSyncPayload +import com.devil.phoenixproject.data.sync.PortalSyncPullResponse +import com.devil.phoenixproject.data.sync.PortalSyncPushResponse +import com.devil.phoenixproject.data.sync.PortalTokenStorage +import com.devil.phoenixproject.data.sync.SupabaseConfig +import io.ktor.client.engine.HttpClientEngine + +class QaBlockingPortalApiClient( + supabaseConfig: SupabaseConfig, + tokenStorage: PortalTokenStorage, + private val fixtureGate: ProfileQaFixtureGate, + httpClientEngine: HttpClientEngine? = null, +) : PortalApiClient(supabaseConfig, tokenStorage, httpClientEngine) { + override suspend fun pushPortalPayload( + payload: PortalSyncPayload, + ): Result = if (fixtureGate.isEnabled()) { + localOnlyFailure() + } else { + super.pushPortalPayload(payload) + } + + override suspend fun pullPortalPayload( + knownEntityIds: KnownEntityIds, + deviceId: String, + profileId: String?, + cursor: String?, + pageSize: Int?, + ): Result = if (fixtureGate.isEnabled()) { + localOnlyFailure() + } else { + super.pullPortalPayload(knownEntityIds, deviceId, profileId, cursor, pageSize) + } + + private fun localOnlyFailure(): Result = + Result.failure(PortalApiException("QA fixture is local-only")) +} diff --git a/androidApp/src/main/kotlin/com/devil/phoenixproject/VitruvianApp.kt b/androidApp/src/main/kotlin/com/devil/phoenixproject/VitruvianApp.kt index b1bfa0e63..0f494994f 100644 --- a/androidApp/src/main/kotlin/com/devil/phoenixproject/VitruvianApp.kt +++ b/androidApp/src/main/kotlin/com/devil/phoenixproject/VitruvianApp.kt @@ -20,7 +20,7 @@ import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidLogger import org.koin.dsl.module -class VitruvianApp : +open class VitruvianApp : Application(), SingletonImageLoader.Factory { @@ -55,7 +55,7 @@ class VitruvianApp : ) } - // Run migrations after Koin is initialized + // Start the required profile preference migration gate after Koin is initialized. migrationManager.checkAndRunMigrations() // H11: Register ActivityHolder via lifecycle callbacks instead of manual diff --git a/androidApp/src/test/kotlin/com/devil/phoenixproject/qa/QaReleaseBoundaryTest.kt b/androidApp/src/test/kotlin/com/devil/phoenixproject/qa/QaReleaseBoundaryTest.kt new file mode 100644 index 000000000..da597a9a5 --- /dev/null +++ b/androidApp/src/test/kotlin/com/devil/phoenixproject/qa/QaReleaseBoundaryTest.kt @@ -0,0 +1,132 @@ +package com.devil.phoenixproject.qa + +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class QaReleaseBoundaryTest { + private val expectedForbiddenReleaseMarkers = listOf( + "ProfileQa", + "QaBlockingPortalApiClient", + "QA_SEED_PROFILE", + "[QA] Profile", + "QA fixture is local-only", + "profile_qa_fixture_gate", + "com.devil.phoenixproject.qa", + ) + + @Test + fun `shared forbidden marker inventory covers the complete QA invariant`() { + val markerFile = forbiddenMarkerFile() + assertTrue("Missing shared QA release marker inventory: $markerFile", markerFile.isFile) + + assertEquals(expectedForbiddenReleaseMarkers, forbiddenReleaseMarkers()) + } + + @Test + fun `profile QA runtime files live in the debug source set`() { + val repoRoot = findRepoRoot() + val requiredDebugFiles = listOf( + "androidApp/src/debug/AndroidManifest.xml", + "androidApp/src/debug/kotlin/com/devil/phoenixproject/qa/ProfileQaDebugApp.kt", + "androidApp/src/debug/kotlin/com/devil/phoenixproject/qa/ProfileQaFixtureGate.kt", + "androidApp/src/debug/kotlin/com/devil/phoenixproject/qa/QaBlockingPortalApiClient.kt", + ) + + val missing = requiredDebugFiles.filterNot { File(repoRoot, it).isFile } + assertTrue( + "Missing required debug-only QA files:\n${missing.joinToString("\n")}", + missing.isEmpty(), + ) + } + + @Test + fun `profile QA rack snapshots use typed serialization instead of JSON literals`() { + val source = File( + findRepoRoot(), + "androidApp/src/debug/kotlin/com/devil/phoenixproject/qa/ProfileQaSeeder.kt", + ).readText() + + assertTrue(source.contains("encodeRackItemsJson(listOf(rackItem))")) + assertFalse(source.contains("[{\\\"id\\\":")) + } + + @Test + fun `QA runtime sources are confined to debug while tests and docs remain allowed`() { + val repoRoot = findRepoRoot() + val sourceRoot = File(repoRoot, "androidApp/src") + val leaks = sourceRoot.walkTopDown() + .filter { it.isFile && it.extension.lowercase() in setOf("kt", "java", "xml") } + .filter { file -> + val normalizedPath = file.relativeTo(repoRoot).invariantSeparatorsPath + !isAllowedQaPath(normalizedPath) && + ( + forbiddenReleaseMarkers().any(file.readText()::contains) || + file.name.contains("ProfileQa") || + file.name.contains("QaBlockingPortalApiClient") + ) + } + .map { it.relativeTo(repoRoot).invariantSeparatorsPath } + .toList() + + assertTrue( + "QA runtime source escaped src/debug:\n${leaks.joinToString("\n")}", + leaks.isEmpty(), + ) + } + + @Test + fun `release source inputs contain no QA markers`() { + val repoRoot = findRepoRoot() + val forbiddenReleaseMarkers = forbiddenReleaseMarkers() + val releaseInputs = listOf( + File(repoRoot, "androidApp/src/main"), + File(repoRoot, "androidApp/src/release"), + ).filter(File::exists) + + val leaks = releaseInputs.flatMap { input -> + val files = if (input.isDirectory) input.walkTopDown().filter(File::isFile) else sequenceOf(input) + files.mapNotNull { file -> + val content = runCatching { file.readText() }.getOrDefault("") + val markers = forbiddenReleaseMarkers.filter(content::contains) + if (markers.isEmpty()) { + null + } else { + "${file.relativeTo(repoRoot).invariantSeparatorsPath}: ${markers.joinToString()}" + } + }.toList() + } + + assertTrue( + "Release inputs contain debug-only QA markers:\n${leaks.joinToString("\n")}", + leaks.isEmpty(), + ) + } + + private fun isAllowedQaPath(path: String): Boolean = path.contains("/src/debug/") || + path.contains("/src/test/") || + path.contains("/src/testDebug/") || + path.contains("/src/androidTest/") || + path.contains("/docs/") + + private fun forbiddenMarkerFile(): File = File(findRepoRoot(), "androidApp/config/qa-release-forbidden-markers.txt") + + private fun forbiddenReleaseMarkers(): List = forbiddenMarkerFile() + .readLines() + .map(String::trim) + .filter(String::isNotEmpty) + + private fun findRepoRoot(): File { + val workingDirectory = requireNotNull(System.getProperty("user.dir")) { + "user.dir is not available" + } + var current = File(workingDirectory).absoluteFile + while (true) { + if (File(current, "settings.gradle.kts").isFile) return current + current = current.parentFile + ?: error("Could not locate repo root from $workingDirectory") + } + } +} diff --git a/androidApp/src/test/kotlin/com/devil/phoenixproject/qa/Schema42FixtureContractTest.kt b/androidApp/src/test/kotlin/com/devil/phoenixproject/qa/Schema42FixtureContractTest.kt new file mode 100644 index 000000000..3021b6429 --- /dev/null +++ b/androidApp/src/test/kotlin/com/devil/phoenixproject/qa/Schema42FixtureContractTest.kt @@ -0,0 +1,126 @@ +package com.devil.phoenixproject.qa + +import java.io.File +import javax.xml.parsers.DocumentBuilderFactory +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class Schema42FixtureContractTest { + @Test + fun fixtureGuideWaitsForRequiredProfileMigrationBeforeInspection() { + val guide = File(findRepoRoot(), "docs/qa/profile-schema42-fixture.md") + .readText() + .replace("\r\n", "\n") + val readinessBlock = listOf( + "\$migrationDeadline = [DateTime]::UtcNow.AddSeconds(60)", + "\$migrationReady = \$false", + "do {", + " \$migrationLine = & \$adb shell run-as \$package grep -F profile_preferences_legacy_migration_complete_v1 shared_prefs/vitruvian_preferences.xml 2>\$null", + " \$migrationReady = \$LASTEXITCODE -eq 0 -and \$migrationLine -match 'value=\"true\"'", + " if (-not \$migrationReady) { Start-Sleep -Seconds 1 }", + "} while (-not \$migrationReady -and [DateTime]::UtcNow -lt \$migrationDeadline)", + "if (-not \$migrationReady) { throw 'Timed out after 60 seconds waiting for required profile preference migration' }", + ).joinToString("\n") + + val readinessStart = guide.indexOf(readinessBlock) + assertTrue( + "Guide must contain the complete bounded migration-readiness polling block", + readinessStart >= 0, + ) + val readinessEnd = readinessStart + readinessBlock.length + val forceStop = guide.indexOf("& \$adb shell am force-stop \$package", startIndex = readinessEnd) + val sqlInspection = guide.indexOf("sqlite3 databases/vitruvian.db", startIndex = forceStop) + assertTrue( + "Guide must wait for migration before force-stop and SQL inspection", + readinessEnd <= forceStop && forceStop < sqlInspection, + ) + } + + @Test + fun schema42FixturePinsTheLegacyUpgradeContract() { + val repoRoot = findRepoRoot() + val fixtureFile = File( + repoRoot, + "docs/qa/fixtures/profile-schema42/vitruvian_preferences.xml", + ) + val guideFile = File(repoRoot, "docs/qa/profile-schema42-fixture.md") + + assertTrue("Expected tracked fixture XML at ${fixtureFile.path}", fixtureFile.isFile) + assertTrue("Expected fixture reproduction guide at ${guideFile.path}", guideFile.isFile) + + val document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(fixtureFile) + assertEquals("map", document.documentElement.tagName) + + assertEntry(document, "string", "weight_unit", text = "KG") + assertEntry(document, "float", "body_weight_kg", value = "82.5") + assertEntry(document, "float", "weight_increment", value = "2.5") + assertEntry(document, "int", "color_scheme", value = "3") + assertEntry(document, "int", "summary_countdown_seconds", value = "15") + assertEntry(document, "int", "autostart_countdown_seconds", value = "7") + assertEntry(document, "int", "velocity_loss_threshold_percent", value = "35") + assertEntry(document, "string", "default_scaling_basis", text = "ESTIMATED_1RM") + + val fixtureText = fixtureFile.readText() + assertFalse( + "The pre-upgrade fixture must not claim profile preference migration is complete", + fixtureText.contains("profile_preferences_legacy_migration_complete_v1"), + ) + assertTrue( + "Expected the rack fixture to contain fixture-vest", + entryText(document, "string", "equipment_rack_items_v1").contains("fixture-vest"), + ) + + val guide = guideFile.readText() + assertTrue( + "Guide must pin the full pre-profile commit SHA", + guide.contains("ac84d9bb8e156002833ad526bf324a8f12710da0"), + ) + assertTrue( + "Guide must pin the exact SQLDelight schema-version diff", + guide.contains( + """- // Version 41 = initial schema (1) + 40 migrations (1.sqm through 40.sqm). +- version = 41 ++ // Version 42 = initial schema (1) + 41 migrations (1.sqm through 41.sqm). ++ version = 42""", + ), + ) + } + + private fun assertEntry( + document: org.w3c.dom.Document, + tag: String, + name: String, + value: String? = null, + text: String? = null, + ) { + val nodes = document.getElementsByTagName(tag) + val entry = (0 until nodes.length) + .map { nodes.item(it) as org.w3c.dom.Element } + .singleOrNull { it.getAttribute("name") == name } + assertTrue("Expected exactly one <$tag> entry named $name", entry != null) + value?.let { assertEquals("Unexpected value for $name", it, entry?.getAttribute("value")) } + text?.let { assertEquals("Unexpected text for $name", it, entry?.textContent) } + } + + private fun entryText(document: org.w3c.dom.Document, tag: String, name: String): String { + val nodes = document.getElementsByTagName(tag) + return (0 until nodes.length) + .map { nodes.item(it) as org.w3c.dom.Element } + .single { it.getAttribute("name") == name } + .textContent + } + + private fun findRepoRoot(): File { + val workingDirectory = requireNotNull(System.getProperty("user.dir")) { + "user.dir is not available" + } + var current = File(workingDirectory).absoluteFile + while (true) { + if (File(current, "settings.gradle.kts").isFile) return current + current = current.parentFile + ?: error("Could not locate repo root from $workingDirectory") + } + } +} diff --git a/androidApp/src/testDebug/kotlin/com/devil/phoenixproject/qa/ProfileQaSeedReceiverTest.kt b/androidApp/src/testDebug/kotlin/com/devil/phoenixproject/qa/ProfileQaSeedReceiverTest.kt new file mode 100644 index 000000000..c13a8aef3 --- /dev/null +++ b/androidApp/src/testDebug/kotlin/com/devil/phoenixproject/qa/ProfileQaSeedReceiverTest.kt @@ -0,0 +1,129 @@ +package com.devil.phoenixproject.qa + +import android.content.Intent +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ProfileQaSeedReceiverTest { + @Test + fun `receiver enables gate before async seed and always finishes success`() = runTest { + val events = mutableListOf() + val logs = mutableListOf() + val pending = RecordingPendingResult(events) + var launched: (suspend () -> Unit)? = null + val receiver = ProfileQaSeedReceiver( + enableGate = { events += "gate" }, + seed = { + events += "seed" + ProfileQaSeedResult("PROFILE_QA_SEED_OK", "a", "b", 10) + }, + beginAsync = { + events += "goAsync" + pending + }, + launch = { launched = it }, + log = logs::add, + ) + + receiver.onReceive(mockk(), intent(ProfileQaSeedReceiver.ACTION_SEED_PROFILE)) + assertEquals(listOf("gate", "goAsync"), events) + launched?.invoke() ?: error("Seed coroutine was not launched") + + assertEquals(listOf("gate", "goAsync", "seed", "finish"), events) + assertEquals(listOf("PROFILE_QA_SEED_OK"), logs) + } + + @Test + fun `receiver finishes and logs stable category when seed fails`() = runTest { + val events = mutableListOf() + val logs = mutableListOf() + val pending = RecordingPendingResult(events) + var launched: (suspend () -> Unit)? = null + val receiver = ProfileQaSeedReceiver( + enableGate = { events += "gate" }, + seed = { + events += "seed" + throw IllegalStateException("Phoenix Alpha must not appear in logs") + }, + beginAsync = { + events += "goAsync" + pending + }, + launch = { launched = it }, + log = logs::add, + ) + + receiver.onReceive(mockk(), intent(ProfileQaSeedReceiver.ACTION_SEED_PROFILE)) + launched?.invoke() ?: error("Seed coroutine was not launched") + + assertEquals(listOf("gate", "goAsync", "seed", "finish"), events) + assertEquals(listOf("PROFILE_QA_SEED_FAILED:IllegalStateException"), logs) + assertFalse(logs.single().contains("Phoenix Alpha")) + } + + @Test + fun `synchronous gate failure aborts before goAsync or first seed write`() { + val events = mutableListOf() + val logs = mutableListOf() + val receiver = ProfileQaSeedReceiver( + enableGate = { + events += "gate" + throw IllegalStateException("commit failed") + }, + seed = { + events += "seed" + error("must not seed") + }, + beginAsync = { + events += "goAsync" + RecordingPendingResult(events) + }, + launch = { error("must not launch") }, + log = logs::add, + ) + + receiver.onReceive(mockk(), intent(ProfileQaSeedReceiver.ACTION_SEED_PROFILE)) + + assertEquals(listOf("gate"), events) + assertEquals(listOf("PROFILE_QA_SEED_FAILED:IllegalStateException"), logs) + } + + @Test + fun `debug manifest exports only the explicit QA seed action`() { + val manifest = locateDebugManifest().readText() + + assertTrue(manifest.contains(".qa.ProfileQaSeedReceiver")) + assertTrue(manifest.contains("android:exported=\"true\"")) + assertTrue(manifest.contains("com.devil.phoenixproject.QA_SEED_PROFILE")) + } + + private fun intent(action: String): Intent = mockk().also { + every { it.action } returns action + } + + private fun locateDebugManifest(): java.io.File { + val start = java.io.File(requireNotNull(System.getProperty("user.dir"))).absoluteFile + return generateSequence(start) { it.parentFile } + .flatMap { parent -> + sequenceOf( + java.io.File(parent, "androidApp/src/debug/AndroidManifest.xml"), + java.io.File(parent, "src/debug/AndroidManifest.xml"), + ) + } + .firstOrNull { it.isFile } + ?: error("Could not locate androidApp/src/debug/AndroidManifest.xml from $start") + } + + private class RecordingPendingResult( + private val events: MutableList, + ) : ProfileQaSeedPendingResult { + override fun finish() { + events += "finish" + } + } +} diff --git a/androidApp/src/testDebug/kotlin/com/devil/phoenixproject/qa/ProfileQaSeederTest.kt b/androidApp/src/testDebug/kotlin/com/devil/phoenixproject/qa/ProfileQaSeederTest.kt new file mode 100644 index 000000000..5c43edcd9 --- /dev/null +++ b/androidApp/src/testDebug/kotlin/com/devil/phoenixproject/qa/ProfileQaSeederTest.kt @@ -0,0 +1,442 @@ +package com.devil.phoenixproject.qa + +import com.devil.phoenixproject.data.repository.AssessmentRepository +import com.devil.phoenixproject.data.repository.AssessmentResultEntity +import com.devil.phoenixproject.data.repository.ExerciseRepository +import com.devil.phoenixproject.data.repository.PersonalRecordRepository +import com.devil.phoenixproject.data.repository.RepMetricRepository +import com.devil.phoenixproject.data.repository.UserProfile +import com.devil.phoenixproject.data.repository.UserProfileRepository +import com.devil.phoenixproject.data.repository.VelocityOneRepMaxEntity +import com.devil.phoenixproject.data.repository.VelocityOneRepMaxRepository +import com.devil.phoenixproject.data.repository.WorkoutRepository +import com.devil.phoenixproject.database.VitruvianDatabase +import com.devil.phoenixproject.domain.model.CoreProfilePreferences +import com.devil.phoenixproject.domain.model.Exercise +import com.devil.phoenixproject.domain.model.LedPreferences +import com.devil.phoenixproject.domain.model.PRType +import com.devil.phoenixproject.domain.model.PersonalRecord +import com.devil.phoenixproject.domain.model.ProfileLocalSafetyPreferences +import com.devil.phoenixproject.domain.model.RackItemBehavior +import com.devil.phoenixproject.domain.model.RackPreferences +import com.devil.phoenixproject.domain.model.ScalingBasis +import com.devil.phoenixproject.domain.model.VbtPreferences +import com.devil.phoenixproject.domain.model.WeightUnit +import com.devil.phoenixproject.domain.model.WorkoutPreferences +import com.devil.phoenixproject.domain.model.WorkoutSession +import com.devil.phoenixproject.domain.onerepmax.VelocityOneRepMaxResult +import com.devil.phoenixproject.util.OneRepMaxCalculator +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.yield +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ProfileQaSeederTest { + @Test + fun `concurrent seed requests serialize into one idempotent fixture`() = runTest { + val fixture = SeederFixture() + val seeder = fixture.seeder() + + awaitAll( + async { seeder.seed() }, + async { seeder.seed() }, + ) + + assertEquals(1, fixture.allProfiles.value.count { it.name == "[QA] Profile A" }) + assertEquals(1, fixture.allProfiles.value.count { it.name == "[QA] Profile B" }) + assertEquals(10, fixture.sessions.size) + assertEquals(2, fixture.assessments.size) + assertEquals(2, fixture.velocity.size) + } + + @Test + fun `fixture PR writes restore the preexisting catalog one rep max`() = runTest { + val fixture = SeederFixture() + + fixture.seeder().seed() + + assertEquals(42f, fixture.catalogOneRepMaxKg) + } + + @Test + fun `two seed runs reuse exact profiles and leave five fixed completed sessions each`() = runTest { + val fixture = SeederFixture() + val seeder = fixture.seeder() + + val first = seeder.seed() + val second = seeder.seed() + + assertEquals("PROFILE_QA_SEED_OK", first.marker) + assertEquals(first.profileAId, second.profileAId) + assertEquals(first.profileBId, second.profileBId) + assertEquals( + listOf("[QA] Profile A", "[QA] Profile B"), + fixture.allProfiles.value.filter { it.name.startsWith("[QA]") }.map { it.name }, + ) + assertTrue("QA profiles must never be reset through profile deletion", fixture.deletedProfiles.isEmpty()) + + listOf(first.profileAId, first.profileBId).forEachIndexed { profileIndex, profileId -> + val expectedIds = (1..5).map { "profile-qa-${if (profileIndex == 0) "a" else "b"}-session-$it" } + val sessions = fixture.sessions.values.filter { it.profileId == profileId }.sortedBy { it.timestamp } + assertEquals(expectedIds, sessions.map { it.id }) + assertEquals( + listOf(1_700_100_000_000L, 1_700_200_000_000L, 1_700_300_000_000L, 1_700_400_000_000L, 1_700_500_000_000L), + sessions.map { it.timestamp }, + ) + assertEquals(listOf(30f, 35f, 40f, 45f, 50f), sessions.map { it.weightPerCableKg }) + assertTrue(sessions.all { it.exerciseId == "bench-press" && it.exerciseName == "Bench Press" }) + assertTrue(sessions.all { it.workingReps > 0 && it.totalReps == it.workingReps }) + assertTrue(sessions.all { (it.avgMcvMmS ?: 0f) > 0f }) + assertTrue(sessions.all { (it.peakForceConcentricA ?: 0f) > 0f }) + assertTrue(sessions.all { (it.totalVolumeKg ?: 0f) > 0f }) + sessions.forEach { session -> + assertTrue(session.rackItemsJson.contains("\"name\"")) + assertTrue(session.rackItemsJson.contains("\"weightKg\"")) + assertTrue( + session.rackItemsJson.contains( + if (profileIndex == 0) "profile-qa-a-rack-vest" else "profile-qa-b-rack-assist", + ), + ) + } + + sessions.forEach { session -> + val metrics = fixture.repMetrics.getValue(session.id) + assertEquals(session.workingReps, metrics.size) + assertEquals((1..session.workingReps).toList(), metrics.map { it.repNumber }) + assertTrue(metrics.all { it.startTimestamp >= session.timestamp }) + assertTrue(metrics.all { it.peakVelocity > 0f && it.peakForceA > 0f && it.peakPowerWatts > 0f }) + } + } + } + + @Test + fun `profiles receive complete visibly inverse typed preferences and deterministic racks`() = runTest { + val fixture = SeederFixture() + val result = fixture.seeder().seed() + val a = result.profileAId + val b = result.profileBId + + assertEquals(CoreProfilePreferences(82.5f, WeightUnit.KG, 2.5f), fixture.core.getValue(a)) + assertEquals(CoreProfilePreferences(165f, WeightUnit.LB, 5f), fixture.core.getValue(b)) + + val rackA = fixture.rack.getValue(a) + val rackB = fixture.rack.getValue(b) + assertEquals(listOf("profile-qa-a-rack-vest"), rackA.items.map { it.id }) + assertEquals(listOf("profile-qa-b-rack-assist"), rackB.items.map { it.id }) + assertEquals(RackItemBehavior.ADDED_RESISTANCE, rackA.items.single().behavior) + assertEquals(RackItemBehavior.COUNTERWEIGHT, rackB.items.single().behavior) + assertNotEquals(rackA, rackB) + + val workoutA = fixture.workout.getValue(a) + val workoutB = fixture.workout.getValue(b) + assertTrue(workoutA.stopAtTop) + assertFalse(workoutB.stopAtTop) + assertTrue(workoutA.beepsEnabled) + assertFalse(workoutB.beepsEnabled) + assertTrue(workoutA.weightSuggestionsEnabled) + assertFalse(workoutB.weightSuggestionsEnabled) + assertEquals(15, workoutA.summaryCountdownSeconds) + assertEquals(5, workoutB.summaryCountdownSeconds) + assertNotEquals(workoutA, workoutB) + + assertEquals(LedPreferences(colorScheme = 1, discoModeUnlocked = false), fixture.led.getValue(a)) + assertEquals(LedPreferences(colorScheme = 4, discoModeUnlocked = true), fixture.led.getValue(b)) + + val vbtA = fixture.vbt.getValue(a) + val vbtB = fixture.vbt.getValue(b) + assertTrue(vbtA.enabled) + assertFalse(vbtB.enabled) + assertEquals(ScalingBasis.ESTIMATED_1RM, vbtA.defaultScalingBasis) + assertEquals(ScalingBasis.MAX_VOLUME_PR, vbtB.defaultScalingBasis) + assertNotEquals(vbtA, vbtB) + + assertEquals( + ProfileLocalSafetyPreferences( + safeWord = "Phoenix Alpha", + safeWordCalibrated = true, + adultsOnlyConfirmed = true, + adultsOnlyPrompted = true, + ), + fixture.localSafety.getValue(a), + ) + assertEquals( + ProfileLocalSafetyPreferences( + safeWord = "Phoenix Bravo", + safeWordCalibrated = false, + adultsOnlyConfirmed = false, + adultsOnlyPrompted = false, + ), + fixture.localSafety.getValue(b), + ) + assertTrue( + fixture.preferenceWriteOrder.indexOf(a to "localSafety") < + fixture.preferenceWriteOrder.indexOf(a to "vbt"), + ) + assertFalse(result.toString().contains("Phoenix Alpha")) + assertFalse(result.toString().contains("Phoenix Bravo")) + } + + @Test + fun `two PR types expose three highlights and VBT history survives disabled profile B`() = runTest { + val fixture = SeederFixture() + val result = fixture.seeder().run { seed().also { seed() } } + + listOf(result.profileAId, result.profileBId).forEach { profileId -> + val prs = fixture.personalRecords.filter { it.profileId == profileId } + assertEquals(2, prs.size) + val weight = prs.single { it.prType == PRType.MAX_WEIGHT } + val volume = prs.single { it.prType == PRType.MAX_VOLUME } + assertEquals(50f, weight.weightPerCableKg) + assertEquals(3, weight.reps) + assertEquals(40f, volume.weightPerCableKg) + assertEquals(12, volume.reps) + assertEquals(480f, volume.volume) + val highlights = Triple( + prs.maxOf { it.weightPerCableKg }, + prs.maxOf { it.oneRepMax }, + prs.maxOf { it.volume }, + ) + assertEquals(50f, highlights.first) + assertEquals(OneRepMaxCalculator.estimate(40f, 12), highlights.second) + assertEquals(480f, highlights.third) + + val assessment = fixture.assessments.single { it.profileId == profileId } + assertEquals(120f, assessment.estimatedOneRepMaxKg) + assertTrue(assessment.loadVelocityData.contains("velocityMs")) + + val velocity = fixture.velocity.single { it.profileId == profileId } + assertEquals(77f, velocity.estimatedPerCableKg) + assertEquals(0.30f, velocity.mvtUsedMs) + assertEquals(0.97f, velocity.r2) + assertEquals(3, velocity.distinctLoads) + assertTrue(velocity.passedQualityGate) + assertTrue(velocity.computedAt > assessment.createdAt) + assertTrue(velocity.estimatedPerCableKg > assessment.estimatedOneRepMaxKg / 2f) + val sessionFallback = fixture.sessions.values + .filter { it.profileId == profileId } + .maxOf { OneRepMaxCalculator.estimate(it.weightPerCableKg, it.workingReps) } + assertTrue(velocity.estimatedPerCableKg > sessionFallback) + } + + assertFalse(fixture.vbt.getValue(result.profileBId).enabled) + assertEquals(1, fixture.assessments.count { it.profileId == result.profileBId }) + assertEquals(1, fixture.velocity.count { it.profileId == result.profileBId }) + } + + private class SeederFixture { + val profiles = mockk(relaxed = true) + val exercises = mockk(relaxed = true) + val workouts = mockk(relaxed = true) + val repMetricRepository = mockk(relaxed = true) + val personalRecordRepository = mockk(relaxed = true) + val assessmentRepository = mockk(relaxed = true) + val velocityRepository = mockk(relaxed = true) + val database = mockk(relaxed = true) + + val allProfiles = MutableStateFlow>(emptyList()) + val deletedProfiles = mutableListOf() + val core = mutableMapOf() + val rack = mutableMapOf() + val workout = mutableMapOf() + val led = mutableMapOf() + val vbt = mutableMapOf() + val localSafety = mutableMapOf() + val preferenceWriteOrder = mutableListOf>() + val sessions = linkedMapOf() + val repMetrics = mutableMapOf>() + val personalRecords = mutableListOf() + val assessments = mutableListOf() + val velocity = mutableListOf() + var catalogOneRepMaxKg: Float? = 42f + + private var nextProfile = 1 + private var nextPr = 1L + private var nextAssessment = 1L + private var nextVelocity = 1L + + init { + every { profiles.allProfiles } returns allProfiles + coEvery { profiles.createProfile(any(), any()) } coAnswers { + yield() + UserProfile( + id = "qa-created-${nextProfile++}", + name = firstArg(), + colorIndex = secondArg(), + createdAt = 1_700_000_000_000L, + isActive = false, + ).also { allProfiles.value = allProfiles.value + it } + } + coEvery { profiles.updateProfile(any(), any(), any()) } coAnswers { + val id = firstArg() + allProfiles.value = allProfiles.value.map { + if (it.id == id) it.copy(name = secondArg(), colorIndex = thirdArg()) else it + } + } + coEvery { profiles.deleteProfile(any()) } coAnswers { + deletedProfiles += firstArg() + true + } + coEvery { profiles.updateCore(any(), any()) } coAnswers { core[firstArg()] = secondArg() } + coEvery { profiles.updateRack(any(), any()) } coAnswers { rack[firstArg()] = secondArg() } + coEvery { profiles.updateWorkout(any(), any()) } coAnswers { workout[firstArg()] = secondArg() } + coEvery { profiles.updateLed(any(), any()) } coAnswers { led[firstArg()] = secondArg() } + coEvery { profiles.updateVbt(any(), any()) } coAnswers { + vbt[firstArg()] = secondArg() + preferenceWriteOrder += firstArg() to "vbt" + } + coEvery { profiles.updateLocalSafety(any(), any()) } coAnswers { + localSafety[firstArg()] = secondArg() + preferenceWriteOrder += firstArg() to "localSafety" + } + + coEvery { exercises.findByName("Bench Press") } coAnswers { + Exercise( + id = "bench-press", + name = "Bench Press", + muscleGroup = "Chest", + equipment = "BAR", + oneRepMaxKg = catalogOneRepMaxKg, + ) + } + coEvery { exercises.updateOneRepMax("bench-press", any()) } coAnswers { + catalogOneRepMaxKg = secondArg() + } + + coEvery { workouts.deleteSession(any()) } coAnswers { sessions.remove(firstArg()) } + coEvery { workouts.saveSession(any()) } coAnswers { + val session = firstArg() + sessions[session.id] = session + } + coEvery { repMetricRepository.deleteRepMetrics(any()) } coAnswers { repMetrics.remove(firstArg()) } + coEvery { repMetricRepository.saveRepMetrics(any(), any()) } coAnswers { + repMetrics[firstArg()] = secondArg() + } + + coEvery { personalRecordRepository.getAllPRsForExercise(any(), any()) } coAnswers { + personalRecords.filter { it.exerciseId == firstArg() && it.profileId == secondArg() } + } + coEvery { + personalRecordRepository.updatePRsIfBetter( + any(), any(), any(), any(), any(), any(), any(), any(), + ) + } coAnswers { + val exerciseId = arg(0) + val weightForWeightPr = arg(1) + val weightForVolumePr = arg(2) + val reps = arg(3) + val workoutMode = arg(4) + val timestamp = arg(5) + val profileId = arg(6) + val cableCount = arg(7) + val broken = mutableListOf() + fun replace(type: PRType, weight: Float, volume: Float) { + val current = personalRecords.firstOrNull { + it.profileId == profileId && it.exerciseId == exerciseId && + it.workoutMode == workoutMode && it.prType == type + } + val improves = current == null || when (type) { + PRType.MAX_WEIGHT -> weight > current.weightPerCableKg + PRType.MAX_VOLUME -> volume > current.volume + } + if (improves) { + personalRecords.remove(current) + personalRecords += PersonalRecord( + id = nextPr++, + exerciseId = exerciseId, + exerciseName = "Bench Press", + weightPerCableKg = weight, + reps = reps, + oneRepMax = OneRepMaxCalculator.estimate(weightForWeightPr, reps), + timestamp = timestamp, + workoutMode = workoutMode, + prType = type, + volume = volume, + profileId = profileId, + cableCount = cableCount, + ) + broken += type + } + } + replace(PRType.MAX_WEIGHT, weightForWeightPr, weightForWeightPr * reps) + replace(PRType.MAX_VOLUME, weightForVolumePr, weightForVolumePr * reps) + if (broken.isNotEmpty()) { + catalogOneRepMaxKg = maxOf( + catalogOneRepMaxKg ?: 0f, + OneRepMaxCalculator.estimate(weightForWeightPr, reps), + ) + } + Result.success(broken) + } + + every { assessmentRepository.getAssessmentsByExercise(any(), any()) } answers { + flowOf(assessments.filter { it.exerciseId == firstArg() && it.profileId == secondArg() }) + } + coEvery { assessmentRepository.deleteAssessment(any()) } coAnswers { + assessments.removeAll { it.id == firstArg() } + } + coEvery { assessmentRepository.saveAssessment(any(), any(), any(), any(), any(), any()) } coAnswers { + val id = nextAssessment++ + assessments += AssessmentResultEntity( + id = id, + exerciseId = arg(0), + estimatedOneRepMaxKg = arg(1), + loadVelocityData = arg(2), + assessmentSessionId = arg(3), + userOverrideKg = arg(4), + createdAt = 1_800_000_000_000L, + profileId = arg(5), + ) + id + } + + every { velocityRepository.getHistory(any(), any()) } answers { + flowOf(velocity.filter { it.exerciseId == firstArg() && it.profileId == secondArg() }) + } + coEvery { velocityRepository.insert(any(), any(), any(), any()) } coAnswers { + val result = firstArg() + velocity += VelocityOneRepMaxEntity( + id = nextVelocity++, + exerciseId = secondArg(), + estimatedPerCableKg = result.estimatedPerCableKg, + mvtUsedMs = result.mvtUsedMs, + r2 = result.r2, + distinctLoads = result.distinctLoads, + passedQualityGate = result.passedQualityGate, + computedAt = arg(2), + profileId = arg(3), + ) + } + + } + + fun seeder() = ProfileQaSeeder( + userProfileRepository = profiles, + exerciseRepository = exercises, + workoutRepository = workouts, + repMetricRepository = repMetricRepository, + personalRecordRepository = personalRecordRepository, + assessmentRepository = assessmentRepository, + velocityOneRepMaxRepository = velocityRepository, + database = database, + fixtureRowCleanup = object : ProfileQaFixtureRowCleanup { + override fun deletePersonalRecord(id: Long) { + personalRecords.removeAll { it.id == id } + } + + override fun deleteVelocityOneRepMax(id: Long) { + velocity.removeAll { it.id == id } + } + }, + ) + } +} diff --git a/androidApp/src/testDebug/kotlin/com/devil/phoenixproject/qa/QaBlockingPortalApiClientTest.kt b/androidApp/src/testDebug/kotlin/com/devil/phoenixproject/qa/QaBlockingPortalApiClientTest.kt new file mode 100644 index 000000000..225d49cf1 --- /dev/null +++ b/androidApp/src/testDebug/kotlin/com/devil/phoenixproject/qa/QaBlockingPortalApiClientTest.kt @@ -0,0 +1,132 @@ +package com.devil.phoenixproject.qa + +import android.content.Context +import android.content.SharedPreferences +import com.devil.phoenixproject.data.sync.GoTrueAuthResponse +import com.devil.phoenixproject.data.sync.GoTrueUser +import com.devil.phoenixproject.data.sync.KnownEntityIds +import com.devil.phoenixproject.data.sync.PortalApiException +import com.devil.phoenixproject.data.sync.PortalSyncPayload +import com.devil.phoenixproject.data.sync.PortalTokenStorage +import com.devil.phoenixproject.data.sync.SupabaseConfig +import com.russhwolf.settings.MapSettings +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.headersOf +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import java.util.concurrent.atomic.AtomicInteger +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class QaBlockingPortalApiClientTest { + @Test + fun `enable persists the gate synchronously in a private preferences file`() { + val fixture = gateFixture() + + fixture.gate.enable() + + assertTrue(fixture.gate.isEnabled()) + verify(exactly = 1) { + fixture.context.getSharedPreferences("profile_qa_fixture_gate", Context.MODE_PRIVATE) + fixture.editor.commit() + } + } + + @Test + fun `enabled gate blocks push locally before a Ktor request`() = runTest { + val requestCount = AtomicInteger(0) + val client = blockingClient(requestCount) + + val result = client.pushPortalPayload( + PortalSyncPayload(deviceId = "qa-device", lastSync = 0), + ) + + assertLocalOnlyFailure(result.exceptionOrNull()) + assertEquals("No HTTP request may escape the local fixture gate", 0, requestCount.get()) + } + + @Test + fun `enabled gate blocks pull locally before a Ktor request`() = runTest { + val requestCount = AtomicInteger(0) + val client = blockingClient(requestCount) + + val result = client.pullPortalPayload( + knownEntityIds = KnownEntityIds(), + deviceId = "qa-device", + ) + + assertLocalOnlyFailure(result.exceptionOrNull()) + assertEquals("No HTTP request may escape the local fixture gate", 0, requestCount.get()) + } + + private fun blockingClient(requestCount: AtomicInteger): QaBlockingPortalApiClient { + val gate = gateFixture().gate.apply { enable() } + val engine = MockEngine { + requestCount.incrementAndGet() + respond( + content = "{}", + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()), + ) + } + return QaBlockingPortalApiClient( + supabaseConfig = SupabaseConfig("https://fake.supabase.co", "anon-key"), + tokenStorage = authenticatedStorage(), + fixtureGate = gate, + httpClientEngine = engine, + ) + } + + private fun authenticatedStorage() = PortalTokenStorage(MapSettings()).apply { + saveGoTrueAuth( + GoTrueAuthResponse( + accessToken = "access-token", + tokenType = "bearer", + expiresIn = 3600, + expiresAt = 4_102_444_800L, + refreshToken = "refresh-token", + user = GoTrueUser(id = "user-id", email = "qa@example.com"), + ), + ) + } + + private fun gateFixture(): GateFixture { + val context = mockk() + val preferences = mockk() + val editor = mockk() + var enabled = false + + every { + context.getSharedPreferences("profile_qa_fixture_gate", Context.MODE_PRIVATE) + } returns preferences + every { preferences.getBoolean("enabled", false) } answers { enabled } + every { preferences.edit() } returns editor + every { editor.putBoolean("enabled", true) } returns editor + every { editor.commit() } answers { + enabled = true + true + } + + return GateFixture( + context = context, + editor = editor, + gate = ProfileQaFixtureGate(context), + ) + } + + private fun assertLocalOnlyFailure(error: Throwable?) { + assertTrue("Expected PortalApiException but was $error", error is PortalApiException) + assertEquals("QA fixture is local-only", error?.message) + } + + private data class GateFixture( + val context: Context, + val editor: SharedPreferences.Editor, + val gate: ProfileQaFixtureGate, + ) +} diff --git a/docs/backend-handoff/profile-preference-byte-goldens.json b/docs/backend-handoff/profile-preference-byte-goldens.json new file mode 100644 index 000000000..185346716 --- /dev/null +++ b/docs/backend-handoff/profile-preference-byte-goldens.json @@ -0,0 +1,9 @@ +{ + "version": 1, + "paddingMarker": "__ASCII_PADDING__", + "sectionMarker": "__SECTION_JSON__", + "sectionRawTemplate": "{\"localProfileId\":\"profile-a\",\"section\":\"RACK\",\"documentVersion\":1,\"baseRevision\":0,\"clientModifiedAt\":\"2026-07-11T12:00:00Z\",\"payload\":{\"version\":1,\"items\":[{\"id\":\"rack-a\",\"name\":\"π界🙂\\\"\\\\__ASCII_PADDING__\",\"category\":\"OTHER\",\"weightKg\":20.0,\"behavior\":\"DISPLAY_ONLY\",\"enabled\":true,\"sortOrder\":0,\"createdAt\":-1e3,\"updatedAt\":0}]}}", + "requestRawTemplate": "{\"deviceId\":\"golden-device\",\"platform\":\"android\",\"lastSync\":0,\"profileId\":\"profile-a\",\"profileName\":\"π界🙂\\\"\\\\__ASCII_PADDING__\",\"profilePreferenceSections\":[__SECTION_JSON__]}", + "sectionTargetBytes": [262143, 262144, 262145], + "requestTargetBytes": [524287, 524288, 524289] +} diff --git a/docs/backend-handoff/profile-preferences-edge-functions.md b/docs/backend-handoff/profile-preferences-edge-functions.md new file mode 100644 index 000000000..58610b885 --- /dev/null +++ b/docs/backend-handoff/profile-preferences-edge-functions.md @@ -0,0 +1,1475 @@ +# Profile Preference Edge Function Handoff + +This is a local-only backend handoff. The mobile repository does not deploy an Edge Function, apply a remote migration, or mutate a Supabase project. The portal implementer must follow the current official [Edge authorization guidance](https://supabase.com/docs/guides/functions/auth), [row-level-security guidance](https://supabase.com/docs/guides/database/postgres/row-level-security), and [Data API exposure change](https://supabase.com/changelog/45329-breaking-change-tables-not-exposed-to-data-and-graphql-api-automatically). + +Create or update only these portal targets: + +```text +supabase/functions/mobile-sync-push/index.ts +supabase/functions/mobile-sync-push/index.test.ts +supabase/functions/mobile-sync-pull/index.ts +supabase/functions/mobile-sync-pull/index.test.ts +supabase/functions/_shared/profile-preference-byte-goldens.json +supabase/tests/database/profile_preferences.test.sql +supabase/config.toml +supabase/migrations/ (created by: supabase migration new profile_preferences) +docs/profile-preferences-advisor-dispositions.md +``` + +The SQL in `profile-preferences-supabase.sql` is the migration source. Create the portal migration with the CLI rather than inventing a filename. The canonical projection and mutation RPC are `SECURITY INVOKER`; client roles have neither direct table DML nor function execution, and only `service_role` receives the explicitly listed privileges. + +## Shared wire types and response semantics + +```typescript +type JsonRecord = Record; +type ProfilePreferenceSection = "CORE" | "RACK" | "WORKOUT" | "LED" | "VBT"; + +interface PortalProfilePreferenceSectionMutation { + localProfileId: string; + section: ProfilePreferenceSection; + documentVersion: number; + baseRevision: number; + clientModifiedAt: string; + payload: Record; +} + +interface PortalProfilePreferenceSectionCanonical { + localProfileId: string; + section: ProfilePreferenceSection; + documentVersion: number; + serverRevision: number; + serverUpdatedAt: string; + payload: Record; +} + +interface ProfilePreferenceSectionRejection { + localProfileId: string; + section: string; + serverRevision: number; + reason: + | "REVISION_CONFLICT" + | "VALIDATION_FAILED" + | "UNSUPPORTED_SECTION" + | "UNSUPPORTED_DOCUMENT_VERSION" + | "SECTION_TOO_LARGE" + | "DUPLICATE_SECTION" + | "UNKNOWN_PROFILE"; + canonicalSection?: PortalProfilePreferenceSectionCanonical; +} + +interface MobileSyncPushAdditions { + profilePreferenceSections?: PortalProfilePreferenceSectionMutation[]; +} + +interface MobileSyncPushResponseAdditions { + profilePreferencesAccepted?: boolean; + canonicalProfilePreferenceSections: PortalProfilePreferenceSectionCanonical[]; + profilePreferenceRejections: ProfilePreferenceSectionRejection[]; +} + +interface MobileSyncPullResponseAdditions { + profilePreferenceSections?: PortalProfilePreferenceSectionCanonical[]; +} +``` + +`REVISION_CONFLICT`, `VALIDATION_FAILED`, `UNSUPPORTED_SECTION`, `UNSUPPORTED_DOCUMENT_VERSION`, and `UNKNOWN_PROFILE` are domain rejections returned by the RPC and may coexist with successful siblings. `SECTION_TOO_LARGE` and `DUPLICATE_SECTION` are Edge validation rejections. A bearer header must contain exactly one non-whitespace token after the literal `Bearer ` prefix; missing, blank, whitespace-bearing, multi-token, and otherwise malformed suffixes are definitive credential rejections and return 401 before `getUser` or admin construction. A returned `auth.getUser` Auth error whose numeric status is exactly 400, 401, or 403 also returns 401. Returned Auth errors with 429, 5xx, any other or missing status, result objects missing their own `error` or `data` discriminants, malformed success or no-user results, and thrown or rejected calls are operational auth failures: return a generic 503, log exactly one object containing only `{ name }`, and never construct the admin client. Transport, PostgREST/RPC, permission, timeout, malformed-RPC-row, and pull-query failures are likewise sanitized infrastructure 5xx responses and are never relabeled as domain rejection. Version `1` is the only supported wrapper and embedded document version. + +## Exact raw-byte contract + +Copy `profile-preference-byte-goldens.json` byte-for-byte to `supabase/functions/_shared/profile-preference-byte-goldens.json`. Before using it, Deno tests must compare the portal copy's SHA-256 with this handoff artifact. Kotlin and Deno parse only the artifact wrapper and preserve both raw template strings verbatim. + +For a section golden, assert the padding marker appears exactly once, replace it with enough ASCII `x` bytes to reach the requested section target, and assert the final UTF-8 count. For a request golden, first replace `__SECTION_JSON__` with the valid section template containing one ASCII padding byte, then replace the request padding marker with enough ASCII `x` bytes to reach the complete-body target. Keep the `20.0` decimal lexeme, `-1e3` exponent lexeme, escaped quote/backslash, and multibyte `π界🙂` unchanged. + +The 9,500,000-byte cap applies to the original HTTP byte sequence only when `profilePreferenceSections` is absent. When that key is present, the 524,288-byte cap applies to the complete original raw `PortalSyncPayload`, including whitespace and ordinary fields. The 262,144-byte cap applies to each exact raw array-element span; never reconstruct it with `JSON.stringify`. Reject malformed UTF-8 and any leading UTF-8 BOM before parsing. Exact limits are inclusive. Kotlin verifies decoder/scanner parity; Deno invokes the real raw-byte handler and verifies 400/413 responses and privileged-call suppression. + +## Push validation and raw scanner + +Place this executable contract in `mobile-sync-push/index.ts`, or import it unchanged from a tested sibling module. Validation is strict: unknown keys fail and no schema library may strip or coerce values. + +```typescript +type ValidationReason = + | "VALIDATION_FAILED" + | "UNSUPPORTED_SECTION" + | "UNSUPPORTED_DOCUMENT_VERSION"; + +class PreferenceValidationError extends Error { + constructor( + readonly reason: ValidationReason, + readonly field: string, + ) { + super("Invalid profile preference field: " + field); + this.name = "PreferenceValidationError"; + } +} + +class PreferenceInfrastructureError extends Error { + constructor(readonly operation: string) { + super("Profile preference infrastructure failure"); + this.name = "PreferenceInfrastructureError"; + } +} + +const MAX_PROFILE_PREFERENCE_SECTION_BYTES = 262_144; +const MAX_PROFILE_PREFERENCE_REQUEST_BYTES = 524_288; +const MAX_MOBILE_SYNC_REQUEST_BYTES = 9_500_000; +const INT32_MIN = -2_147_483_648; +const INT32_MAX = 2_147_483_647; +const utf8Bytes = (rawJson: string): number => + new TextEncoder().encode(rawJson).byteLength; + +const PUSH_BODY_KEYS = new Set([ + "deviceId", + "platform", + "lastSync", + "sessions", + "telemetry", + "routines", + "deletedRoutineIds", + "cycles", + "deletedCycleIds", + "rpgAttributes", + "badges", + "gamificationStats", + "phaseStatistics", + "exerciseSignatures", + "assessments", + "customExercises", + "profileId", + "profileName", + "allProfiles", + "externalActivities", + "personalRecords", + "profilePreferenceSections", +]); + +const MUTATION_KEYS = [ + "localProfileId", + "section", + "documentVersion", + "baseRevision", + "clientModifiedAt", + "payload", +] as const; + +const LOCAL_ONLY_KEYS = new Set([ + "safeword", + "safewordcalibrated", + "adultsonlyconfirmed", + "adultsonlyprompted", + "localgeneration", + "dirty", + "legacymigrationversion", +]); + +const normalizeKey = (key: string): string => + key.replace(/[^a-z0-9]/gi, "").toLowerCase(); + +const fail = ( + field: string, + reason: ValidationReason = "VALIDATION_FAILED", +): never => { + throw new PreferenceValidationError(reason, field); +}; + +const requireRecord = (value: unknown, field: string): JsonRecord => { + if (typeof value !== "object" || value === null || Array.isArray(value)) fail(field); + return value as JsonRecord; +}; + +const requirePostgresString = (value: unknown, field: string): string => { + if (typeof value !== "string") fail(field); + for (let index = 0; index < value.length; index += 1) { + const codeUnit = value.charCodeAt(index); + if (codeUnit === 0) fail(field); + if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (!(next >= 0xdc00 && next <= 0xdfff)) fail(field); + index += 1; + } else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) { + fail(field); + } + } + return value; +}; + +function requirePostgresTextTree(value: unknown, field: string): void { + if (typeof value === "string") { + requirePostgresString(value, field); + return; + } + if (Array.isArray(value)) { + value.forEach((child, index) => requirePostgresTextTree(child, field + "[" + index + "]")); + return; + } + if (typeof value !== "object" || value === null) return; + Object.entries(value as JsonRecord).forEach(([key, child]) => { + requirePostgresString(key, field + "."); + requirePostgresTextTree(child, field + "." + key); + }); +} + +const requireExactRecord = ( + value: unknown, + keys: readonly string[], + field: string, +): JsonRecord => { + const record = requireRecord(value, field); + requirePostgresTextTree(record, field); + const allowed = new Set(keys); + for (const key of Object.keys(record)) { + if (!allowed.has(key)) fail(field + "." + key); + } + for (const key of keys) { + if (!Object.hasOwn(record, key)) fail(field + "." + key); + } + return record; +}; + +const requireKnownKeys = ( + record: JsonRecord, + allowed: ReadonlySet, + field: string, +): void => { + for (const key of Object.keys(record)) { + if (!allowed.has(key)) fail(field + "." + key); + } +}; + +const requireArray = (value: unknown, field: string): unknown[] => { + if (!Array.isArray(value)) fail(field); + return value; +}; + +interface RawJsonSpan { + start: number; + end: number; +} + +interface TopLevelJsonScan { + valueSpans: Map; + duplicateKeys: Set; +} + +const skipJsonWhitespace = (raw: string, from: number): number => { + let index = from; + while (index < raw.length && /[\u0009\u000a\u000d\u0020]/.test(raw[index])) index += 1; + return index; +}; + +function scanJsonString(raw: string, start: number): number { + if (raw[start] !== '"') fail("rawJson.string"); + let index = start + 1; + while (index < raw.length) { + const character = raw[index]; + if (character === '"') return index + 1; + if (character === "\\") index += 2; + else index += 1; + } + fail("rawJson.unterminatedString"); +} + +function scanJsonValue(raw: string, from: number, depth = 0): number { + if (depth > 256) fail("rawJson.depth"); + let index = skipJsonWhitespace(raw, from); + if (raw[index] === '"') return scanJsonString(raw, index); + if (raw[index] === "[") { + index = skipJsonWhitespace(raw, index + 1); + if (raw[index] === "]") return index + 1; + while (index < raw.length) { + index = skipJsonWhitespace(raw, scanJsonValue(raw, index, depth + 1)); + if (raw[index] === "]") return index + 1; + if (raw[index] !== ",") fail("rawJson.arrayDelimiter"); + index = skipJsonWhitespace(raw, index + 1); + } + fail("rawJson.unterminatedArray"); + } + if (raw[index] === "{") { + index = skipJsonWhitespace(raw, index + 1); + if (raw[index] === "}") return index + 1; + while (index < raw.length) { + const keyEnd = scanJsonString(raw, index); + index = skipJsonWhitespace(raw, keyEnd); + if (raw[index] !== ":") fail("rawJson.objectColon"); + index = skipJsonWhitespace(raw, scanJsonValue(raw, index + 1, depth + 1)); + if (raw[index] === "}") return index + 1; + if (raw[index] !== ",") fail("rawJson.objectDelimiter"); + index = skipJsonWhitespace(raw, index + 1); + } + fail("rawJson.unterminatedObject"); + } + const tokenStart = index; + while (index < raw.length && !/[\u0009\u000a\u000d\u0020,\]}]/.test(raw[index])) { + index += 1; + } + if (index === tokenStart) fail("rawJson.value"); + return index; +} +``` + +```typescript +function scanTopLevelJsonObject(raw: string): TopLevelJsonScan { + let index = skipJsonWhitespace(raw, 0); + if (raw[index] !== "{") fail("body"); + index = skipJsonWhitespace(raw, index + 1); + const valueSpans = new Map(); + const duplicateKeys = new Set(); + if (raw[index] === "}") { + index = skipJsonWhitespace(raw, index + 1); + if (index !== raw.length) fail("rawJson.trailingData"); + return { valueSpans, duplicateKeys }; + } + while (index < raw.length) { + const keyStart = index; + const keyEnd = scanJsonString(raw, keyStart); + const key = JSON.parse(raw.slice(keyStart, keyEnd)) as string; + index = skipJsonWhitespace(raw, keyEnd); + if (raw[index] !== ":") fail("rawJson.objectColon"); + const valueStart = skipJsonWhitespace(raw, index + 1); + const valueEnd = scanJsonValue(raw, valueStart); + if (valueSpans.has(key)) duplicateKeys.add(key); + else valueSpans.set(key, { start: valueStart, end: valueEnd }); + index = skipJsonWhitespace(raw, valueEnd); + if (raw[index] === "}") { + index = skipJsonWhitespace(raw, index + 1); + if (index !== raw.length) fail("rawJson.trailingData"); + return { valueSpans, duplicateKeys }; + } + if (raw[index] !== ",") fail("rawJson.objectDelimiter"); + index = skipJsonWhitespace(raw, index + 1); + } + fail("rawJson.unterminatedObject"); +} + +function scanJsonArrayElementSpans(raw: string, arraySpan: RawJsonSpan): RawJsonSpan[] { + let index = skipJsonWhitespace(raw, arraySpan.start); + if (raw[index] !== "[") fail("body.profilePreferenceSections"); + index = skipJsonWhitespace(raw, index + 1); + const spans: RawJsonSpan[] = []; + if (raw[index] === "]") { + if (index + 1 !== arraySpan.end) fail("body.profilePreferenceSections.span"); + return spans; + } + while (index < arraySpan.end) { + const start = index; + const end = scanJsonValue(raw, start); + spans.push({ start, end }); + index = skipJsonWhitespace(raw, end); + if (raw[index] === "]") { + if (index + 1 !== arraySpan.end) fail("body.profilePreferenceSections.span"); + return spans; + } + if (raw[index] !== ",") fail("body.profilePreferenceSections.delimiter"); + index = skipJsonWhitespace(raw, index + 1); + } + fail("body.profilePreferenceSections.span"); +} + +const sameJsonValue = (left: unknown, right: unknown): boolean => + JSON.stringify(left) === JSON.stringify(right); + +const requireBoolean = (value: unknown, field: string): boolean => { + if (typeof value !== "boolean") fail(field); + return value; +}; + +const requireFloat32 = ( + value: unknown, + field: string, + predicate: (number: number) => boolean = () => true, +): number => { + if (typeof value !== "number" || !Number.isFinite(value) || !predicate(value)) fail(field); + const narrowed = Math.fround(value); + if (!Number.isFinite(narrowed) || (value !== 0 && narrowed === 0) || !predicate(narrowed)) { + fail(field); + } + return narrowed; +}; + +const requireInt32 = ( + value: unknown, + field: string, + predicate: (number: number) => boolean = () => true, +): number => { + if ( + typeof value !== "number" || + !Number.isInteger(value) || + value < INT32_MIN || + value > INT32_MAX || + !predicate(value) + ) fail(field); + return value; +}; + +const requireSafeJsonLong = ( + value: unknown, + field: string, + predicate: (number: number) => boolean = () => true, +): number => { + if (typeof value !== "number" || !Number.isSafeInteger(value) || !predicate(value)) fail(field); + return value; +}; + +const requireNonBlank = (value: unknown, field: string): string => { + const text = requirePostgresString(value, field); + if (text.trim().length === 0) fail(field); + return text; +}; + +const requireEnum = ( + value: unknown, + allowed: readonly T[], + field: string, +): T => { + const text = requirePostgresString(value, field); + if (!allowed.includes(text as T)) fail(field); + return text as T; +}; + +const requireVersionOne = (value: unknown, field: string): 1 => { + const version = requireInt32(value, field); + if (version !== 1) fail(field, "UNSUPPORTED_DOCUMENT_VERSION"); + return 1; +}; + +const RFC3339_INSTANT = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|([+-])(\d{2}):(\d{2}))$/; + +const isLeapYear = (year: number): boolean => + year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + +const daysInMonth = (year: number, month: number): number => + [31, isLeapYear(year) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1] ?? 0; + +const requireRfc3339Instant = (value: unknown, field: string): string => { + const text = requirePostgresString(value, field); + const match = RFC3339_INSTANT.exec(text); + if (!match) fail(field); + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const hour = Number(match[4]); + const minute = Number(match[5]); + const second = Number(match[6]); + const offsetHour = match[8] === "Z" ? 0 : Number(match[10]); + const offsetMinute = match[8] === "Z" ? 0 : Number(match[11]); + if ( + year < 1 || + month < 1 || month > 12 || + day < 1 || day > daysInMonth(year, month) || + hour > 23 || minute > 59 || second > 59 || + offsetHour > 23 || offsetMinute > 59 + ) fail(field); + const epoch = Date.parse(text); + if (!Number.isFinite(epoch)) fail(field); + const normalized = new Date(epoch); + return normalized.toISOString(); +}; + +const rejectLocalOnlyKeys = (value: unknown, field = "profilePreferenceSections"): void => { + if (Array.isArray(value)) { + value.forEach((child, index) => rejectLocalOnlyKeys(child, field + "[" + index + "]")); + return; + } + if (typeof value !== "object" || value === null) return; + for (const [key, child] of Object.entries(value as JsonRecord)) { + if (LOCAL_ONLY_KEYS.has(normalizeKey(key))) fail(field + "." + key); + rejectLocalOnlyKeys(child, field + "." + key); + } +}; + +const RACK_ITEM_KEYS = [ + "id", + "name", + "category", + "weightKg", + "behavior", + "enabled", + "sortOrder", + "createdAt", + "updatedAt", +] as const; +const RACK_CATEGORIES = [ + "WEIGHTED_VEST", + "DIP_BELT", + "CHAINS", + "BAND", + "ASSISTANCE", + "ATTACHMENT", + "OTHER", +] as const; +const RACK_BEHAVIORS = [ + "ADDED_RESISTANCE", + "COUNTERWEIGHT", + "DISPLAY_ONLY", +] as const; +const WORKOUT_MODES = [0, 2, 3, 4, 6, 10] as const; +const REP_COUNT_TIMINGS = ["TOP", "BOTTOM"] as const; + +function validateCorePayload(value: unknown): JsonRecord { + const payload = requireExactRecord( + value, + ["bodyWeightKg", "weightUnit", "weightIncrement"], + "payload", + ); + requireFloat32( + payload.bodyWeightKg, + "payload.bodyWeightKg", + (number) => number === 0 || (number >= 20 && number <= 300), + ); + requireEnum(payload.weightUnit, ["KG", "LB"] as const, "payload.weightUnit"); + requireFloat32( + payload.weightIncrement, + "payload.weightIncrement", + (number) => number === -1 || number > 0, + ); + return payload; +} + +function validateRackPayload(value: unknown): JsonRecord { + const payload = requireExactRecord(value, ["version", "items"], "payload"); + requireVersionOne(payload.version, "payload.version"); + const ids = new Set(); + requireArray(payload.items, "payload.items").forEach((rawItem, index) => { + const field = "payload.items[" + index + "]"; + const item = requireExactRecord(rawItem, RACK_ITEM_KEYS, field); + const id = requireNonBlank(item.id, field + ".id"); + requireNonBlank(item.name, field + ".name"); + if (ids.has(id)) fail(field + ".id"); + ids.add(id); + requireEnum(item.category, RACK_CATEGORIES, field + ".category"); + requireFloat32(item.weightKg, field + ".weightKg", (number) => number >= 0); + requireEnum(item.behavior, RACK_BEHAVIORS, field + ".behavior"); + requireBoolean(item.enabled, field + ".enabled"); + requireInt32(item.sortOrder, field + ".sortOrder"); + requireSafeJsonLong(item.createdAt, field + ".createdAt"); + requireSafeJsonLong(item.updatedAt, field + ".updatedAt"); + }); + return payload; +} +``` + +```typescript +const JUST_LIFT_KEYS = [ + "workoutModeId", + "weightPerCableKg", + "weightChangePerRep", + "eccentricLoadPercentage", + "echoLevelValue", + "stallDetectionEnabled", + "repCountTimingName", + "restSeconds", +] as const; + +const SINGLE_EXERCISE_KEYS = [ + "exerciseId", + "setReps", + "weightPerCableKg", + "setWeightsPerCableKg", + "progressionKg", + "setRestSeconds", + "workoutModeId", + "eccentricLoadPercentage", + "echoLevelValue", + "duration", + "isAMRAP", + "perSetRestTime", + "defaultRackItemIds", +] as const; + +const WORKOUT_KEYS = [ + "version", + "stopAtTop", + "beepsEnabled", + "stallDetectionEnabled", + "audioRepCountEnabled", + "repCountTiming", + "summaryCountdownSeconds", + "autoStartCountdownSeconds", + "gamificationEnabled", + "autoStartRoutine", + "countdownBeepsEnabled", + "repSoundEnabled", + "motionStartEnabled", + "weightSuggestionsEnabled", + "defaultRoutineExerciseUsePercentOfPR", + "defaultRoutineExerciseWeightPercentOfPR", + "voiceStopEnabled", + "justLiftDefaults", + "singleExerciseDefaults", +] as const; + +function validateJustLiftDefaults(value: unknown, field: string): void { + const defaults = requireExactRecord(value, JUST_LIFT_KEYS, field); + requireInt32( + defaults.workoutModeId, + field + ".workoutModeId", + (number) => WORKOUT_MODES.includes(number as typeof WORKOUT_MODES[number]), + ); + requireFloat32(defaults.weightPerCableKg, field + ".weightPerCableKg", (number) => number >= 0); + requireFloat32(defaults.weightChangePerRep, field + ".weightChangePerRep"); + requireInt32( + defaults.eccentricLoadPercentage, + field + ".eccentricLoadPercentage", + (number) => number >= 0 && number <= 150, + ); + requireInt32( + defaults.echoLevelValue, + field + ".echoLevelValue", + (number) => number >= 0 && number <= 3, + ); + requireBoolean(defaults.stallDetectionEnabled, field + ".stallDetectionEnabled"); + requireEnum(defaults.repCountTimingName, REP_COUNT_TIMINGS, field + ".repCountTimingName"); + requireInt32( + defaults.restSeconds, + field + ".restSeconds", + (number) => number === 0 || (number >= 5 && number <= 300), + ); +} + +function validateSingleExerciseDefaults( + mapKey: string, + value: unknown, + field: string, +): void { + const defaults = requireExactRecord(value, SINGLE_EXERCISE_KEYS, field); + const exerciseId = requireNonBlank(defaults.exerciseId, field + ".exerciseId"); + if (mapKey.trim().length === 0 || exerciseId !== mapKey) fail(field + ".exerciseId"); + requireArray(defaults.setReps, field + ".setReps").forEach((rep, index) => { + if (rep !== null) { + requireInt32(rep, field + ".setReps[" + index + "]", (number) => number >= 0); + } + }); + requireFloat32(defaults.weightPerCableKg, field + ".weightPerCableKg", (number) => number >= 0); + requireArray(defaults.setWeightsPerCableKg, field + ".setWeightsPerCableKg") + .forEach((weight, index) => requireFloat32( + weight, + field + ".setWeightsPerCableKg[" + index + "]", + (number) => number >= 0, + )); + requireFloat32(defaults.progressionKg, field + ".progressionKg"); + requireArray(defaults.setRestSeconds, field + ".setRestSeconds") + .forEach((rest, index) => requireInt32( + rest, + field + ".setRestSeconds[" + index + "]", + (number) => number === 0 || (number >= 5 && number <= 300), + )); + requireInt32( + defaults.workoutModeId, + field + ".workoutModeId", + (number) => WORKOUT_MODES.includes(number as typeof WORKOUT_MODES[number]), + ); + requireInt32( + defaults.eccentricLoadPercentage, + field + ".eccentricLoadPercentage", + (number) => number >= 0 && number <= 150, + ); + requireInt32( + defaults.echoLevelValue, + field + ".echoLevelValue", + (number) => number >= 0 && number <= 3, + ); + requireInt32(defaults.duration, field + ".duration", (number) => number >= 0); + requireBoolean(defaults.isAMRAP, field + ".isAMRAP"); + requireBoolean(defaults.perSetRestTime, field + ".perSetRestTime"); + const rackIds = requireArray(defaults.defaultRackItemIds, field + ".defaultRackItemIds") + .map((rackId, index) => requireNonBlank( + rackId, + field + ".defaultRackItemIds[" + index + "]", + )); + if (new Set(rackIds).size !== rackIds.length) fail(field + ".defaultRackItemIds"); +} + +function validateWorkoutPayload(value: unknown): JsonRecord { + const payload = requireExactRecord(value, WORKOUT_KEYS, "payload"); + requireVersionOne(payload.version, "payload.version"); + [ + "stopAtTop", + "beepsEnabled", + "stallDetectionEnabled", + "audioRepCountEnabled", + "gamificationEnabled", + "autoStartRoutine", + "countdownBeepsEnabled", + "repSoundEnabled", + "motionStartEnabled", + "weightSuggestionsEnabled", + "defaultRoutineExerciseUsePercentOfPR", + "voiceStopEnabled", + ].forEach((key) => requireBoolean(payload[key], "payload." + key)); + requireEnum(payload.repCountTiming, REP_COUNT_TIMINGS, "payload.repCountTiming"); + requireInt32( + payload.summaryCountdownSeconds, + "payload.summaryCountdownSeconds", + (number) => [-1, 0, 5, 10, 15, 20, 25, 30].includes(number), + ); + requireInt32( + payload.autoStartCountdownSeconds, + "payload.autoStartCountdownSeconds", + (number) => number >= 2 && number <= 10, + ); + requireInt32( + payload.defaultRoutineExerciseWeightPercentOfPR, + "payload.defaultRoutineExerciseWeightPercentOfPR", + (number) => number >= 50 && number <= 120, + ); + validateJustLiftDefaults(payload.justLiftDefaults, "payload.justLiftDefaults"); + const singleExerciseDefaults = requireRecord( + payload.singleExerciseDefaults, + "payload.singleExerciseDefaults", + ); + Object.entries(singleExerciseDefaults).forEach(([key, defaults]) => + validateSingleExerciseDefaults( + key, + defaults, + "payload.singleExerciseDefaults." + key, + ) + ); + return payload; +} + +function validateLedPayload(value: unknown): JsonRecord { + const payload = requireExactRecord( + value, + ["ledColorSchemeId", "preferences"], + "payload", + ); + requireInt32( + payload.ledColorSchemeId, + "payload.ledColorSchemeId", + (number) => number >= 0, + ); + const preferences = requireExactRecord( + payload.preferences, + ["version", "discoModeUnlocked"], + "payload.preferences", + ); + requireVersionOne(preferences.version, "payload.preferences.version"); + requireBoolean(preferences.discoModeUnlocked, "payload.preferences.discoModeUnlocked"); + return payload; +} + +function validateVbtPayload(value: unknown): JsonRecord { + const payload = requireExactRecord(value, ["vbtEnabled", "preferences"], "payload"); + requireBoolean(payload.vbtEnabled, "payload.vbtEnabled"); + const preferences = requireExactRecord( + payload.preferences, + [ + "version", + "velocityLossThresholdPercent", + "autoEndOnVelocityLoss", + "defaultScalingBasis", + "verbalEncouragementEnabled", + "vulgarModeEnabled", + "vulgarTier", + "dominatrixModeUnlocked", + "dominatrixModeActive", + ], + "payload.preferences", + ); + requireVersionOne(preferences.version, "payload.preferences.version"); + requireInt32( + preferences.velocityLossThresholdPercent, + "payload.preferences.velocityLossThresholdPercent", + (number) => number >= 10 && number <= 50, + ); + requireBoolean(preferences.autoEndOnVelocityLoss, "payload.preferences.autoEndOnVelocityLoss"); + requireEnum( + preferences.defaultScalingBasis, + ["MAX_WEIGHT_PR", "MAX_VOLUME_PR", "ESTIMATED_1RM"] as const, + "payload.preferences.defaultScalingBasis", + ); + requireBoolean( + preferences.verbalEncouragementEnabled, + "payload.preferences.verbalEncouragementEnabled", + ); + requireBoolean(preferences.vulgarModeEnabled, "payload.preferences.vulgarModeEnabled"); + requireEnum( + preferences.vulgarTier, + ["MILD", "STRONG", "MIX"] as const, + "payload.preferences.vulgarTier", + ); + requireBoolean(preferences.dominatrixModeUnlocked, "payload.preferences.dominatrixModeUnlocked"); + requireBoolean(preferences.dominatrixModeActive, "payload.preferences.dominatrixModeActive"); + return payload; +} +``` + +```typescript +function parsePreferenceMutation(value: unknown): PortalProfilePreferenceSectionMutation { + requirePostgresTextTree(value, "mutation"); + rejectLocalOnlyKeys(value); + const mutation = requireExactRecord(value, MUTATION_KEYS, "mutation"); + const localProfileId = requireNonBlank(mutation.localProfileId, "mutation.localProfileId"); + if (typeof mutation.section !== "string") fail("mutation.section", "UNSUPPORTED_SECTION"); + if (!["CORE", "RACK", "WORKOUT", "LED", "VBT"].includes(mutation.section)) { + fail("mutation.section", "UNSUPPORTED_SECTION"); + } + const section = mutation.section as ProfilePreferenceSection; + const documentVersion = requireVersionOne( + mutation.documentVersion, + "mutation.documentVersion", + ); + const baseRevision = requireSafeJsonLong( + mutation.baseRevision, + "mutation.baseRevision", + (number) => number >= 0, + ); + const clientModifiedAt = requireRfc3339Instant( + mutation.clientModifiedAt, + "mutation.clientModifiedAt", + ); + const payload = ({ + CORE: validateCorePayload, + RACK: validateRackPayload, + WORKOUT: validateWorkoutPayload, + LED: validateLedPayload, + VBT: validateVbtPayload, + } as const)[section](mutation.payload); + return { + localProfileId, + section, + documentVersion, + baseRevision, + clientModifiedAt, + payload, + }; +} + +interface PreferenceEnvelope { + present: boolean; + validatedMutations: PortalProfilePreferenceSectionMutation[]; + rejections: ProfilePreferenceSectionRejection[]; +} + +interface PreferenceRawContext { + rawBody: string; + preferenceElementSpans: RawJsonSpan[]; +} + +const rawPreferenceIdentity = (value: unknown): string | null => { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + const record = value as JsonRecord; + if (typeof record.localProfileId !== "string" || typeof record.section !== "string") { + return null; + } + return JSON.stringify([record.localProfileId, record.section]); +}; + +const rawPreferenceLabel = (value: unknown): { localProfileId: string; section: string } => { + const record = + typeof value === "object" && value !== null && !Array.isArray(value) + ? value as JsonRecord + : {}; + return { + localProfileId: typeof record.localProfileId === "string" ? record.localProfileId : "", + section: typeof record.section === "string" ? record.section : "UNKNOWN", + }; +}; + +function parsePreferenceEnvelope( + body: JsonRecord, + rawContext: PreferenceRawContext, +): PreferenceEnvelope { + requireKnownKeys(body, PUSH_BODY_KEYS, "body"); + if (!Object.hasOwn(body, "profilePreferenceSections")) { + if (rawContext.preferenceElementSpans.length !== 0) { + fail("body.profilePreferenceSections.span"); + } + return { present: false, validatedMutations: [], rejections: [] }; + } + const rawMutations = requireArray( + body.profilePreferenceSections, + "body.profilePreferenceSections", + ); + if (rawMutations.length !== rawContext.preferenceElementSpans.length) { + fail("body.profilePreferenceSections.span"); + } + rawMutations.forEach((rawMutation, index) => { + const span = rawContext.preferenceElementSpans[index]; + let reparsed: unknown; + try { + reparsed = JSON.parse(rawContext.rawBody.slice(span.start, span.end)); + } catch { + fail("body.profilePreferenceSections.span"); + } + if (!sameJsonValue(reparsed, rawMutation)) fail("body.profilePreferenceSections.span"); + }); + + // Raw-span structural/reparse failures above are envelope-level 400 errors. From this point, + // duplicate, size, and schema/Unicode outcomes are isolated to their section identities. + const identityCounts = new Map(); + rawMutations.forEach((rawMutation) => { + const identity = rawPreferenceIdentity(rawMutation); + if (identity !== null) { + identityCounts.set(identity, (identityCounts.get(identity) ?? 0) + 1); + } + }); + const duplicateIdentities = new Set( + [...identityCounts.entries()] + .filter(([, count]) => count > 1) + .map(([identity]) => identity), + ); + + const validatedMutations: PortalProfilePreferenceSectionMutation[] = []; + const rejections: ProfilePreferenceSectionRejection[] = []; + const duplicateReported = new Set(); + rawMutations.forEach((rawMutation, index) => { + const label = rawPreferenceLabel(rawMutation); + const identity = rawPreferenceIdentity(rawMutation); + if (identity !== null && duplicateIdentities.has(identity)) { + if (!duplicateReported.has(identity)) { + duplicateReported.add(identity); + rejections.push({ + ...label, + serverRevision: 0, + reason: "DUPLICATE_SECTION", + }); + } + return; + } + const span = rawContext.preferenceElementSpans[index]; + const rawPreferenceElementBytes = utf8Bytes( + rawContext.rawBody.slice(span.start, span.end), + ); + if (rawPreferenceElementBytes > MAX_PROFILE_PREFERENCE_SECTION_BYTES) { + rejections.push({ + ...label, + serverRevision: 0, + reason: "SECTION_TOO_LARGE", + }); + return; + } + try { + const mutation = parsePreferenceMutation(rawMutation); + validatedMutations.push(mutation); + } catch (error) { + if (!(error instanceof PreferenceValidationError)) throw error; + rejections.push({ + ...label, + serverRevision: 0, + reason: error.reason, + }); + } + }); + return { present: true, validatedMutations, rejections }; +} +``` + +Rack item IDs must be unique, but rack names may repeat. Signed safe-integer `createdAt` and `updatedAt` values are valid. If an existing local `Long` timestamp is outside JavaScript's safe-integer range, the mobile sync planner must emit a local diagnostic and omit that section until corrected; Edge must never round it. Duplicate `(localProfileId, section)` identities are pre-counted: emit exactly one `DUPLICATE_SECTION` result for the key and execute zero RPCs for every occurrence, while valid non-duplicated siblings continue. + +## Push authentication and privileged boundary + +Authenticate with the caller-scoped anon client. Parse and validate the complete request—including the final ordinary item and final preference item—before constructing the privileged client. `validateExistingMobileSyncPushBody` is the endpoint's existing strict, side-effect-free validator for every non-preference key in `PUSH_BODY_KEYS`; it must neither recurse into nor revalidate `profilePreferenceSections`, because `parsePreferenceMutation` owns that field's per-section Unicode/schema rejection boundary. Wire the ordinary validator to the endpoint's real parser and prove a malformed final ordinary or preference item causes zero admin table/RPC calls. + +```typescript +const authorization = req.headers.get("Authorization"); +const bearerMatch = authorization === null + ? null + : /^Bearer ([^\s]+)$/.exec(authorization); +if (!bearerMatch) { + return new Response(JSON.stringify({ error: "Missing bearer token" }), { status: 401 }); +} +const userJwt = bearerMatch[1]; +const authClient = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { + global: { headers: { Authorization: authorization } }, + auth: { persistSession: false, autoRefreshToken: false }, +}); + +const safeErrorName = (error: unknown, fallback: string): string => { + let candidate = fallback; + if (error instanceof Error) { + candidate = error.name; + } else if ( + typeof error === "object" && error !== null && + typeof (error as JsonRecord).name === "string" + ) { + candidate = (error as JsonRecord).name as string; + } + return /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/.test(candidate) ? candidate : fallback; +}; + +const authOperationalFailure = (error: unknown): Response => { + console.error({ name: safeErrorName(error, "AuthOperationalFailure") }); + return new Response( + JSON.stringify({ error: "Authentication service unavailable" }), + { status: 503 }, + ); +}; + +const returnedAuthStatus = (error: unknown): number | null => { + if (typeof error !== "object" || error === null) return null; + const record = error as JsonRecord; + const status = record.status ?? record.statusCode; + return typeof status === "number" && Number.isInteger(status) ? status : null; +}; + +let authResult: unknown; +try { + authResult = await authClient.auth.getUser(userJwt); +} catch (error) { + return authOperationalFailure(error); +} +if (typeof authResult !== "object" || authResult === null || Array.isArray(authResult)) { + return authOperationalFailure({ name: "AuthUnexpectedResult" }); +} +const authRecord = authResult as JsonRecord; +if (!Object.hasOwn(authRecord, "error") || !Object.hasOwn(authRecord, "data")) { + return authOperationalFailure({ name: "AuthUnexpectedResult" }); +} +const userError = authRecord.error; +if (userError !== null) { + const status = returnedAuthStatus(userError); + if (status === 400 || status === 401 || status === 403) { + return new Response(JSON.stringify({ error: "Invalid bearer token" }), { status: 401 }); + } + return authOperationalFailure(userError); +} +const userData = authRecord.data; +if (typeof userData !== "object" || userData === null || Array.isArray(userData)) { + return authOperationalFailure({ name: "AuthUnexpectedResult" }); +} +const verifiedUser = (userData as JsonRecord).user; +if ( + typeof verifiedUser !== "object" || verifiedUser === null || Array.isArray(verifiedUser) || + typeof (verifiedUser as JsonRecord).id !== "string" || + ((verifiedUser as JsonRecord).id as string).length === 0 +) { + return authOperationalFailure({ name: "AuthUnexpectedResult" }); +} +const verifiedUserId = (verifiedUser as JsonRecord).id as string; + +let originalBodyBytes: Uint8Array; +try { + originalBodyBytes = new Uint8Array(await req.arrayBuffer()); +} catch (error) { + console.error({ name: safeErrorName(error, "RequestBodyReadFailure") }); + return new Response(JSON.stringify({ error: "Request unavailable" }), { status: 503 }); +} +const rawBodyBytes = originalBodyBytes.byteLength; +if (rawBodyBytes > MAX_MOBILE_SYNC_REQUEST_BYTES) { + return new Response(JSON.stringify({ error: "Request too large" }), { status: 413 }); +} +const hasLeadingUtf8Bom = + originalBodyBytes.length >= 3 && + originalBodyBytes[0] === 0xef && + originalBodyBytes[1] === 0xbb && + originalBodyBytes[2] === 0xbf; +if (hasLeadingUtf8Bom) { + return new Response(JSON.stringify({ error: "Invalid sync request" }), { status: 400 }); +} +let rawBody: string; +try { + rawBody = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }) + .decode(originalBodyBytes); +} catch { + return new Response(JSON.stringify({ error: "Invalid sync request" }), { status: 400 }); +} +if (rawBody.startsWith("\uFEFF")) { + return new Response(JSON.stringify({ error: "Invalid sync request" }), { status: 400 }); +} + +let topLevelScan: TopLevelJsonScan; +try { + topLevelScan = scanTopLevelJsonObject(rawBody); + for (const duplicateKey of topLevelScan.duplicateKeys) { + if (PUSH_BODY_KEYS.has(duplicateKey)) fail("body." + duplicateKey); + } +} catch (error) { + if (!(error instanceof PreferenceValidationError) && !(error instanceof SyntaxError)) { + throw error; + } + return new Response(JSON.stringify({ error: "Invalid sync request" }), { status: 400 }); +} +const preferenceValueSpan = topLevelScan.valueSpans.get("profilePreferenceSections"); +if ( + preferenceValueSpan !== undefined && + rawBodyBytes > MAX_PROFILE_PREFERENCE_REQUEST_BYTES +) { + return new Response(JSON.stringify({ error: "Request too large" }), { status: 413 }); +} + +let body: JsonRecord; +let preferenceEnvelope: PreferenceEnvelope; +try { + const preferenceElementSpans = preferenceValueSpan === undefined + ? [] + : scanJsonArrayElementSpans(rawBody, preferenceValueSpan); + const parsedBody = JSON.parse(rawBody) as unknown; + body = requireRecord(parsedBody, "body"); + preferenceEnvelope = parsePreferenceEnvelope(body, { + rawBody, + preferenceElementSpans, + }); + validateExistingMobileSyncPushBody(body); +} catch (error) { + if (!(error instanceof PreferenceValidationError) && !(error instanceof SyntaxError)) { + throw error; + } + return new Response(JSON.stringify({ error: "Invalid sync request" }), { status: 400 }); +} + +const admin = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, { + auth: { persistSession: false, autoRefreshToken: false }, +}); +``` + +The 9,500,000-byte cap counts `originalBodyBytes.byteLength` only when `profilePreferenceSections` is absent. Whenever that top-level field is present, the 524,288-byte cap applies to that same complete original byte sequence, including all whitespace, ordinary fields, and preference fields. Reject oversize requests from the original count, never from a decoded/re-encoded surrogate. The 262,144-byte cap applies to each exact raw JSON array-element span measured by the quote-, escape-, and nesting-aware scanner. Only after one fatal valid-UTF-8 decode and explicit no-BOM check is `TextEncoder.encode(rawBody.slice(span.start, span.end))` byte-identical to the corresponding original octets: valid UTF-8 has a unique encoding and JSON structural offsets fall on scalar boundaries. Never reconstruct spans with `JSON.stringify`. Reject malformed UTF-8, a leading BOM/U+FEFF, duplicate relevant top-level keys, element-count mismatch, and reparsed-span/value mismatch before constructing the admin client. Exact-size payloads are accepted; 262,145 and 524,289 bytes are rejected. + +`clientModifiedAt` is strict timezone-bearing RFC3339 audit metadata normalized by `requireRfc3339Instant`. Never use it, `deviceId`, or a client-generated idempotency value for mutation ordering. + +## RPC result parsing and push response + +Only a successfully parsed single RPC row is a domain result. Empty/multiple/malformed rows, unknown reasons, permission failures, and PostgREST errors are infrastructure failures. + +```typescript +interface RpcMutationRow { + accepted: boolean; + rejection_reason: string | null; + server_revision: number | string; + canonical_section: unknown | null; +} + +const RPC_DOMAIN_REASONS = new Set([ + "REVISION_CONFLICT", + "VALIDATION_FAILED", + "UNSUPPORTED_SECTION", + "UNSUPPORTED_DOCUMENT_VERSION", + "UNKNOWN_PROFILE", +]); + +const infrastructureRevision = (value: unknown): number => { + const number = typeof value === "string" && /^[0-9]+$/.test(value) + ? Number(value) + : value; + if (typeof number !== "number" || !Number.isSafeInteger(number) || number < 0) { + throw new PreferenceInfrastructureError("malformed revision"); + } + return number; +}; + +function parseInfrastructureCanonical( + value: unknown, + mutation: PortalProfilePreferenceSectionMutation, +): PortalProfilePreferenceSectionCanonical { + try { + requirePostgresTextTree(value, "canonical"); + const canonical = requireExactRecord( + value, + [ + "localProfileId", + "section", + "documentVersion", + "serverRevision", + "serverUpdatedAt", + "payload", + ], + "canonical", + ); + if (canonical.localProfileId !== mutation.localProfileId) fail("canonical.localProfileId"); + if (canonical.section !== mutation.section) fail("canonical.section"); + requireVersionOne(canonical.documentVersion, "canonical.documentVersion"); + const serverRevision = infrastructureRevision(canonical.serverRevision); + const serverUpdatedAt = requireRfc3339Instant( + canonical.serverUpdatedAt, + "canonical.serverUpdatedAt", + ); + const payload = ({ + CORE: validateCorePayload, + RACK: validateRackPayload, + WORKOUT: validateWorkoutPayload, + LED: validateLedPayload, + VBT: validateVbtPayload, + } as const)[mutation.section](canonical.payload); + return { + localProfileId: mutation.localProfileId, + section: mutation.section, + documentVersion: 1, + serverRevision, + serverUpdatedAt, + payload, + }; + } catch (error) { + if (error instanceof PreferenceInfrastructureError) throw error; + throw new PreferenceInfrastructureError("malformed canonical"); + } +} + +function parseRpcMutationRow( + data: unknown, + mutation: PortalProfilePreferenceSectionMutation, +): { + accepted: boolean; + rejectionReason: string | null; + serverRevision: number; + canonicalSection?: PortalProfilePreferenceSectionCanonical; +} { + if (!Array.isArray(data) || data.length !== 1) { + throw new PreferenceInfrastructureError("RPC row cardinality"); + } + try { + const row = requireExactRecord( + data[0], + ["accepted", "rejection_reason", "server_revision", "canonical_section"], + "rpcRow", + ) as unknown as RpcMutationRow; + if (typeof row.accepted !== "boolean") fail("rpcRow.accepted"); + const serverRevision = infrastructureRevision(row.server_revision); + const canonicalSection = row.canonical_section === null + ? undefined + : parseInfrastructureCanonical(row.canonical_section, mutation); + if (row.accepted) { + if (row.rejection_reason !== null || !canonicalSection) fail("rpcRow"); + } else { + if ( + typeof row.rejection_reason !== "string" || + !RPC_DOMAIN_REASONS.has(row.rejection_reason) + ) { + fail("rpcRow.rejection_reason"); + } + if (row.rejection_reason === "REVISION_CONFLICT" && !canonicalSection) { + fail("rpcRow.canonical_section"); + } + } + if (canonicalSection && canonicalSection.serverRevision !== serverRevision) fail("rpcRow"); + return { + accepted: row.accepted, + rejectionReason: row.rejection_reason, + serverRevision, + canonicalSection, + }; + } catch (error) { + if (error instanceof PreferenceInfrastructureError) throw error; + throw new PreferenceInfrastructureError("malformed RPC row"); + } +} + +const canonicalProfilePreferenceSections: PortalProfilePreferenceSectionCanonical[] = []; +const profilePreferenceRejections: ProfilePreferenceSectionRejection[] = [ + ...preferenceEnvelope.rejections, +]; + +try { + for (const mutation of preferenceEnvelope.validatedMutations) { + const { data, error } = await admin.rpc("mutate_local_profile_preference_section", { + p_user_id: verifiedUserId, + p_local_profile_id: mutation.localProfileId, + p_section: mutation.section, + p_document_version: mutation.documentVersion, + p_base_revision: mutation.baseRevision, + p_payload: mutation.payload, + }); + if (error) throw new PreferenceInfrastructureError("mutation RPC"); + const result = parseRpcMutationRow(data, mutation); + if (result.accepted) { + canonicalProfilePreferenceSections.push(result.canonicalSection!); + } else { + profilePreferenceRejections.push({ + localProfileId: mutation.localProfileId, + section: mutation.section, + serverRevision: result.serverRevision, + reason: result.rejectionReason as ProfilePreferenceSectionRejection["reason"], + ...(result.canonicalSection + ? { canonicalSection: result.canonicalSection } + : {}), + }); + } + } +} catch (error) { + console.error({ name: safeErrorName(error, "PreferenceInfrastructureFailure") }); + return new Response(JSON.stringify({ error: "Sync temporarily unavailable" }), { + status: 503, + }); +} + +const preferenceResponseAdditions = { + ...(preferenceEnvelope.present ? { profilePreferencesAccepted: true } : {}), + canonicalProfilePreferenceSections, + profilePreferenceRejections, +}; +``` + +Emit `profilePreferencesAccepted: true` only when the field was present, envelope-validated, and evaluated; omit it for legacy callers. Every accepted row adds its canonical object. Well-formed domain rejections include the reason and canonical object when supplied. Local validation rejections coexist with valid siblings. Merge these additions into the existing response without removing or changing `syncTime`. + +Any unexpected RPC/query/permission/transport error aborts the response with a sanitized 5xx. Log only `{ name }`: never log a payload, JWT, service-role secret, profile id, PostgREST message, or raw error message. One RPC invocation owns one complete Postgres transaction, and the Edge function performs no network call while a database lock is held. + +Same-section concurrent first writes must yield exactly one revision-1 acceptance and one canonical conflict. Different-section concurrent first writes for one profile both reach revision 1 and preserve their sibling document. Lost acknowledgement is normal convergence: if A commits and later sibling B fails, return 5xx and acknowledge neither. Retrying A at its old base revision returns `REVISION_CONFLICT` with the committed revision-1 canonical state without incrementing again; the mobile generation ledger applies it, and B retries normally. + +Keep JWT verification enabled: + +```toml +[functions.mobile-sync-push] +verify_jwt = true + +[functions.mobile-sync-pull] +verify_jwt = true +``` + +## Pull contract + +In `mobile-sync-pull/index.ts`, reuse the exact bearer-token/`auth.getUser(userJwt)` classifier above: only returned Auth errors with 400, 401, or 403 become 401; 429, 5xx, any other or missing status, malformed results, and thrown or rejected calls become name-only-logged generic 503 responses. Derive `verifiedUserId` only from the verified user, and construct the service-role client only after successful authentication and strict pull-request validation. Query preferences only on the first page for a nonblank requested profile, apply both owner predicates, and map the typed columns/documents into the exact canonical wrappers returned by mutation. + +```typescript +const canonical = ( + localProfileId: string, + section: ProfilePreferenceSection, + serverRevision: number, + serverUpdatedAt: string, + payload: JsonRecord, +): PortalProfilePreferenceSectionCanonical => ({ + localProfileId, + section, + documentVersion: 1, + serverRevision, + serverUpdatedAt, + payload, +}); + +const canonicalTimestamp = (value: unknown): string => + requireRfc3339Instant(value, "pull.serverUpdatedAt"); + +async function loadFirstPageProfilePreferences( + cursor: string | null | undefined, + requestedProfileId: string | null | undefined, +): Promise { + if (cursor || !requestedProfileId || requestedProfileId.trim().length === 0) { + return undefined; + } + const { data: preferenceRow, error: preferenceError } = await admin + .from("local_profile_preferences") + .select( + "local_profile_id,body_weight_kg,weight_unit,weight_increment," + + "core_revision,core_updated_at,equipment_rack,rack_revision,rack_updated_at," + + "workout_preferences,workout_revision,workout_updated_at," + + "led_color_scheme_id,led_preferences,led_revision,led_updated_at," + + "vbt_enabled,vbt_preferences,vbt_revision,vbt_updated_at", + ) + .eq("user_id", verifiedUserId) + .eq("local_profile_id", requestedProfileId) + .maybeSingle(); + if (preferenceError) throw new PreferenceInfrastructureError("preference pull"); + if (!preferenceRow) return undefined; + + try { + const core = validateCorePayload({ + bodyWeightKg: preferenceRow.body_weight_kg, + weightUnit: preferenceRow.weight_unit, + weightIncrement: preferenceRow.weight_increment, + }); + const rack = validateRackPayload(preferenceRow.equipment_rack); + const workout = validateWorkoutPayload(preferenceRow.workout_preferences); + const led = validateLedPayload({ + ledColorSchemeId: preferenceRow.led_color_scheme_id, + preferences: preferenceRow.led_preferences, + }); + const vbt = validateVbtPayload({ + vbtEnabled: preferenceRow.vbt_enabled, + preferences: preferenceRow.vbt_preferences, + }); + return [ + canonical( + requestedProfileId, + "CORE", + infrastructureRevision(preferenceRow.core_revision), + canonicalTimestamp(preferenceRow.core_updated_at), + core, + ), + canonical( + requestedProfileId, + "RACK", + infrastructureRevision(preferenceRow.rack_revision), + canonicalTimestamp(preferenceRow.rack_updated_at), + rack, + ), + canonical( + requestedProfileId, + "WORKOUT", + infrastructureRevision(preferenceRow.workout_revision), + canonicalTimestamp(preferenceRow.workout_updated_at), + workout, + ), + canonical( + requestedProfileId, + "LED", + infrastructureRevision(preferenceRow.led_revision), + canonicalTimestamp(preferenceRow.led_updated_at), + led, + ), + canonical( + requestedProfileId, + "VBT", + infrastructureRevision(preferenceRow.vbt_revision), + canonicalTimestamp(preferenceRow.vbt_updated_at), + vbt, + ), + ]; + } catch (error) { + if (error instanceof PreferenceInfrastructureError) throw error; + throw new PreferenceInfrastructureError("malformed preference pull row"); + } +} + +const profilePreferenceSections = await loadFirstPageProfilePreferences( + cursor, + requestedProfileId, +); +``` + +An absent row omits the field and never creates a row. Later pages omit the field while retaining normal pagination and `syncTime`. The containing pull handler catches `PreferenceInfrastructureError`, logs only its sanitized class name, and returns a generic 5xx. + +## Required portal test manifest + +```portal-test-manifest +database:exact-function-acls-and-no-client-dml +database:temporary-grant-owner-rls-and-cross-owner-user-id-protection +database:base-revision-accept-and-stale-canonical-conflict +edge:auth-and-cross-user-profile-rejection +edge:auth-rejection-vs-operational-outage-classification +edge:strict-five-section-validation-and-local-only-rejection +edge:kotlin-int32-float32-unicode-and-rfc3339-parity +edge:fatal-utf8-bom-and-original-byte-enforcement +edge:section-262143-262144-262145-byte-boundaries +edge:envelope-524287-524288-524289-byte-boundaries +edge:unexpected-rpc-error-is-sanitized-5xx +edge:same-section-concurrent-first-write +edge:different-section-concurrent-first-write +edge:lost-ack-retry-canonical-convergence +edge:mutation-and-first-page-pull-canonical-equality +edge:later-pull-pages-omit-preferences-and-keep-sync-time +``` + +Implement every manifest entry as executable pgTAP or Deno coverage, not a string search: + +- In `supabase/tests/database/profile_preferences.test.sql`, use pgTAP in a transaction. Assert both exact function signatures, `SECURITY INVOKER`, empty `search_path`, owner, volatility, return shapes, and ACLs. Assert `PUBLIC`, `anon`, and `authenticated` have no function execution or table DML while `service_role` has only the intended privileges. +- Temporarily grant table DML inside the rolled-back pgTAP transaction to test RLS. With authenticated JWT claims for owners A and B, prove CRUD is restricted to each composite parent and owner A cannot update `user_id` to owner B because `WITH CHECK` fails. Do not claim RLS makes a same-owner `local_profile_id` immutable; no trigger is part of this contract. Revoke the temporary grants and reassert production ACLs. +- Seed two composite profile keys and call the real mutation RPC for all five sections. Prove base 0 acceptance at revision 1, matching-base increment, stale-base canonical conflict, sibling preservation, key preservation, and explicit domain rejection rows. +- Push tests use injected anon/admin clients and two real local users. Missing headers plus blank, whitespace-bearing, multi-token, and otherwise malformed `Bearer` suffixes return HTTP 401 without calling `getUser` or constructing/calling admin. Returned Auth errors with each of 400, 401, and 403 also return 401. Separately inject returned 429, 500, and 503 errors, an error without a status, a null, array, or malformed result, results missing their own `error` or `data` discriminants, a non-null error without status, a success without a user, and thrown or rejected `getUser` calls; each returns generic 503 and its captured console call has exactly one argument equal to an object containing exactly the `name` key. Every auth failure constructs and calls zero admin clients. Cross-owner requests and any body `userId` authority fail. Malformed final ordinary or preference items produce zero privileged calls. +- Table-drive required and unknown keys, primitive types, enum and range edges, nested objects, duplicate rack ID, duplicate section identity, all five wrappers, unsupported versions, and recursively normalized local-only names. For Kotlin `Int`, prove rack `sortOrder` accepts exactly -2147483648 and 2147483647 and rejects either adjacent overflow; cover workout `setReps` and `duration` plus LED color scheme with both Int32 and their narrower business rules. For Kotlin `Float`, prove `Float.MAX_VALUE` and the smallest nonzero Float32 survive where business rules allow, while positive and negative Float32 overflow and nonzero underflow-to-zero are rejected; apply every business predicate to both the original JavaScript number and its `Math.fround` result. In CORE, accept exact `bodyWeightKg` values `20` and `300`, but reject exact adjacent inputs `19.9999999` and `300.00001` even though Float32 rounding produces `20` and `300`. For the nonnegative RACK `weightKg` JSONB field, accept `0` and the smallest positive Float32, but reject exact `-1e-46` rather than allowing its `-0` Float32 result through. Safe JSON integers apply only to Long revisions and timestamps. Recursively test raw and escaped U+0000 plus lone high and low surrogates in nested string values and `singleExerciseDefaults` or other object keys; reject each before admin construction, while a valid supplementary pair or emoji passes. Put one such Unicode-invalid unique-key section beside a valid unique-key sibling and assert exactly one `VALIDATION_FAILED` for the invalid key, zero RPC calls for that key, and one successful RPC for the valid sibling. Keep malformed raw-span structure or a raw/parsed element mismatch as an envelope-level HTTP 400 with zero privileged calls. Duplicate rack names and signed safe-integer `createdAt` and `updatedAt` values are accepted. Pre-count duplicate section identities before size or document validation, emit exactly one `DUPLICATE_SECTION` rejection per duplicated key, execute zero RPCs for every occurrence, and still execute each valid non-duplicated sibling once. +- Table-drive the shared strict instant helper through mutation, RPC canonical, and pull paths. Reject numeric and string `0`, prose dates, date-only or space forms, February 30, invalid leap days, times, or offsets, and every missing timezone. Accept valid `Z`, fractional-second, and positive or negative offset instants and assert exact `toISOString()` normalization. +- Send raw `Uint8Array` bodies through the real handler. Reject a leading UTF-8 BOM, truncated sequences, overlong encodings, and isolated continuation bytes with HTTP 400 and zero admin construction or calls; accept a legitimately encoded U+FFFD scalar. Exercise shared UTF-8 goldens at 262143/262144/262145 original bytes per raw section and 524287/524288/524289 original bytes per complete request. Assert inclusive limits from `Uint8Array.byteLength`, HTTP 413 only for complete-request overflow when the preference field is present, per-section `SECTION_TOO_LARGE` for section overflow, and exact scanner offsets through whitespace, escapes, and nesting. A large ordinary-only request below 9,500,000 bytes must not inherit the preference request cap. +- Inject RPC error, null/empty/multiple rows, malformed canonical, mismatched revision, and unknown reason. Each produces a generic 5xx, calls the logger with exactly one `{ name: safeErrorName(...) }` object, and never fabricates `VALIDATION_FAILED`; a valid domain rejection still coexists with valid siblings. Include a thrown error whose custom `name` is invalid or oversized and prove the fallback name is logged. +- Use real RPC calls plus `Promise.all` for same-section and different-section first-write races, and exercise the lost-ack retry convergence path. +- Pull tests repeat the exact auth classification matrix, seed all five documents through mutation, and deep-compare first-page canonical wrappers to mutation responses, including strict RFC3339 `toISOString()` normalization. Inject malformed database timestamps and string or object-key Unicode to prove a name-only-logged generic 5xx rather than silent normalization. Verify both owner predicates, cross-owner isolation, no row creation on absence, later-page omission, and retained `syncTime`. + +## Portal verification and handoff boundary + +Run from the portal repository, with Supabase CLI 2.81.3 or newer for `db advisors`: + +```bash +supabase --version +supabase start +supabase db reset --local +supabase migration list --local +supabase test db --local +deno test --allow-env --allow-net supabase/functions/mobile-sync-push +deno test --allow-env --allow-net supabase/functions/mobile-sync-pull +supabase db lint --local --fail-on warning +supabase db advisors --local +git status --short +git diff --check +git diff --name-only +git diff -- supabase/functions/mobile-sync-push supabase/functions/mobile-sync-pull supabase/functions/_shared/profile-preference-byte-goldens.json supabase/tests/database/profile_preferences.test.sql supabase/config.toml supabase/migrations docs/profile-preferences-advisor-dispositions.md +``` + +If the installed CLI lacks `db advisors`, use the equivalent Supabase Advisors through the project MCP integration or Dashboard before approval; do not skip it. Record each lint/advisor finding with id, severity, object, fix or evidence-backed disposition, command/source, and rerun result in `docs/profile-preferences-advisor-dispositions.md`. + +The portal diff must contain only the named targets. Do not run `supabase db push`, deploy functions, apply a remote migration, create a remote commit, or otherwise mutate a remote Supabase project as part of this handoff. diff --git a/docs/backend-handoff/profile-preferences-supabase.sql b/docs/backend-handoff/profile-preferences-supabase.sql new file mode 100644 index 000000000..3c9dc5465 --- /dev/null +++ b/docs/backend-handoff/profile-preferences-supabase.sql @@ -0,0 +1,425 @@ +BEGIN; + +DO $preflight$ +DECLARE + matching_key text[]; +BEGIN + IF to_regclass('public.local_profiles') IS NULL THEN + RAISE EXCEPTION 'profile preferences preflight: public.local_profiles does not exist'; + END IF; + + SELECT array_agg(attribute.attname::text ORDER BY key_column.ordinality) + INTO matching_key + FROM pg_constraint constraint_row + CROSS JOIN LATERAL unnest(constraint_row.conkey) + WITH ORDINALITY AS key_column(attnum, ordinality) + JOIN pg_attribute attribute + ON attribute.attrelid = constraint_row.conrelid + AND attribute.attnum = key_column.attnum + WHERE constraint_row.conrelid = 'public.local_profiles'::regclass + AND constraint_row.contype IN ('p', 'u') + GROUP BY constraint_row.oid + HAVING array_agg(attribute.attname::text ORDER BY key_column.ordinality) + = ARRAY['user_id', 'id']::text[] + LIMIT 1; + + IF matching_key IS NULL THEN + RAISE EXCEPTION + 'profile preferences preflight: expected unique local_profiles(user_id, id) parent key'; + END IF; +END +$preflight$; + +CREATE TABLE public.local_profile_preferences( + user_id uuid NOT NULL, + local_profile_id text NOT NULL, + schema_version integer NOT NULL DEFAULT 1 CHECK (schema_version = 1), + + body_weight_kg double precision NOT NULL DEFAULT 0, + weight_unit text NOT NULL DEFAULT 'LB', + weight_increment double precision NOT NULL DEFAULT -1, + core_revision bigint NOT NULL DEFAULT 0 CHECK (core_revision >= 0), + core_updated_at timestamptz NOT NULL DEFAULT now(), + + equipment_rack jsonb NOT NULL DEFAULT '{"version":1,"items":[]}'::jsonb, + rack_revision bigint NOT NULL DEFAULT 0 CHECK (rack_revision >= 0), + rack_updated_at timestamptz NOT NULL DEFAULT now(), + + workout_preferences jsonb NOT NULL DEFAULT '{"version":1}'::jsonb, + workout_revision bigint NOT NULL DEFAULT 0 CHECK (workout_revision >= 0), + workout_updated_at timestamptz NOT NULL DEFAULT now(), + + led_color_scheme_id integer NOT NULL DEFAULT 0, + led_preferences jsonb NOT NULL DEFAULT '{"version":1,"discoModeUnlocked":false}'::jsonb, + led_revision bigint NOT NULL DEFAULT 0 CHECK (led_revision >= 0), + led_updated_at timestamptz NOT NULL DEFAULT now(), + + vbt_enabled boolean NOT NULL DEFAULT true, + vbt_preferences jsonb NOT NULL DEFAULT '{"version":1,"velocityLossThresholdPercent":20,"autoEndOnVelocityLoss":false,"defaultScalingBasis":"MAX_WEIGHT_PR","verbalEncouragementEnabled":false,"vulgarModeEnabled":false,"vulgarTier":"STRONG","dominatrixModeUnlocked":false,"dominatrixModeActive":false}'::jsonb, + vbt_revision bigint NOT NULL DEFAULT 0 CHECK (vbt_revision >= 0), + vbt_updated_at timestamptz NOT NULL DEFAULT now(), + + updated_at timestamptz GENERATED ALWAYS AS ( + greatest(core_updated_at, rack_updated_at, workout_updated_at, led_updated_at, vbt_updated_at) + ) STORED, + + CONSTRAINT local_profile_preferences_pkey PRIMARY KEY (user_id, local_profile_id), + CONSTRAINT local_profile_preferences_parent_fkey + FOREIGN KEY (user_id, local_profile_id) + REFERENCES public.local_profiles(user_id, id) + ON DELETE CASCADE, + CONSTRAINT local_profile_preferences_body_weight_check CHECK ( + body_weight_kg NOT IN ('NaN'::double precision, 'Infinity'::double precision, '-Infinity'::double precision) + AND (body_weight_kg = 0 OR body_weight_kg BETWEEN 20 AND 300) + ), + CONSTRAINT local_profile_preferences_weight_unit_check CHECK (weight_unit IN ('KG', 'LB')), + CONSTRAINT local_profile_preferences_weight_increment_check CHECK ( + weight_increment NOT IN ('NaN'::double precision, 'Infinity'::double precision, '-Infinity'::double precision) + AND (weight_increment = -1 OR weight_increment > 0) + ), + CONSTRAINT local_profile_preferences_led_scheme_check CHECK (led_color_scheme_id >= 0), + CONSTRAINT local_profile_preferences_rack_object_check CHECK ( + jsonb_typeof(equipment_rack) = 'object' + AND equipment_rack @> '{"version":1}'::jsonb + AND jsonb_typeof(equipment_rack -> 'items') = 'array' + AND NOT jsonb_path_exists(equipment_rack, '$.items[*] ? (@.weightKg < 0)') + AND octet_length(equipment_rack::text) <= 262144 + ), + CONSTRAINT local_profile_preferences_workout_object_check CHECK ( + jsonb_typeof(workout_preferences) = 'object' + AND workout_preferences @> '{"version":1}'::jsonb + AND ( + NOT workout_preferences ? 'summaryCountdownSeconds' + OR ( + jsonb_typeof(workout_preferences -> 'summaryCountdownSeconds') = 'number' + AND (workout_preferences ->> 'summaryCountdownSeconds')::integer + IN (-1, 0, 5, 10, 15, 20, 25, 30) + ) + ) + AND ( + NOT workout_preferences ? 'autoStartCountdownSeconds' + OR ( + jsonb_typeof(workout_preferences -> 'autoStartCountdownSeconds') = 'number' + AND (workout_preferences ->> 'autoStartCountdownSeconds')::integer BETWEEN 2 AND 10 + ) + ) + AND ( + NOT workout_preferences ? 'defaultRoutineExerciseWeightPercentOfPR' + OR ( + jsonb_typeof(workout_preferences -> 'defaultRoutineExerciseWeightPercentOfPR') = 'number' + AND (workout_preferences ->> 'defaultRoutineExerciseWeightPercentOfPR')::integer BETWEEN 50 AND 120 + ) + ) + AND octet_length(workout_preferences::text) <= 262144 + ), + CONSTRAINT local_profile_preferences_led_object_check CHECK ( + jsonb_typeof(led_preferences) = 'object' + AND led_preferences @> '{"version":1}'::jsonb + AND jsonb_typeof(led_preferences -> 'discoModeUnlocked') = 'boolean' + AND octet_length(led_preferences::text) <= 262144 + ), + CONSTRAINT local_profile_preferences_vbt_object_check CHECK ( + jsonb_typeof(vbt_preferences) = 'object' + AND vbt_preferences @> '{"version":1}'::jsonb + AND jsonb_typeof(vbt_preferences -> 'velocityLossThresholdPercent') = 'number' + AND (vbt_preferences ->> 'velocityLossThresholdPercent')::integer BETWEEN 10 AND 50 + AND octet_length(vbt_preferences::text) <= 262144 + ) +); + +CREATE FUNCTION public.local_profile_preference_section_canonical( + p_row public.local_profile_preferences, + p_section text +) RETURNS jsonb +LANGUAGE sql +STABLE +SECURITY INVOKER +SET search_path = '' +AS $canonical$ + SELECT jsonb_build_object( + 'localProfileId', p_row.local_profile_id, + 'section', p_section, + 'documentVersion', CASE p_section + WHEN 'CORE' THEN 1 + WHEN 'RACK' THEN (p_row.equipment_rack ->> 'version')::integer + WHEN 'WORKOUT' THEN (p_row.workout_preferences ->> 'version')::integer + WHEN 'LED' THEN (p_row.led_preferences ->> 'version')::integer + WHEN 'VBT' THEN (p_row.vbt_preferences ->> 'version')::integer + END, + 'serverRevision', CASE p_section + WHEN 'CORE' THEN p_row.core_revision + WHEN 'RACK' THEN p_row.rack_revision + WHEN 'WORKOUT' THEN p_row.workout_revision + WHEN 'LED' THEN p_row.led_revision + WHEN 'VBT' THEN p_row.vbt_revision + END, + 'serverUpdatedAt', to_char( + (CASE p_section + WHEN 'CORE' THEN p_row.core_updated_at + WHEN 'RACK' THEN p_row.rack_updated_at + WHEN 'WORKOUT' THEN p_row.workout_updated_at + WHEN 'LED' THEN p_row.led_updated_at + WHEN 'VBT' THEN p_row.vbt_updated_at + END) AT TIME ZONE 'UTC', + 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"' + ), + 'payload', CASE p_section + WHEN 'CORE' THEN jsonb_build_object( + 'bodyWeightKg', p_row.body_weight_kg, + 'weightUnit', p_row.weight_unit, + 'weightIncrement', p_row.weight_increment + ) + WHEN 'RACK' THEN p_row.equipment_rack + WHEN 'WORKOUT' THEN p_row.workout_preferences + WHEN 'LED' THEN jsonb_build_object( + 'ledColorSchemeId', p_row.led_color_scheme_id, + 'preferences', p_row.led_preferences + ) + WHEN 'VBT' THEN jsonb_build_object( + 'vbtEnabled', p_row.vbt_enabled, + 'preferences', p_row.vbt_preferences + ) + END + ); +$canonical$; + +CREATE FUNCTION public.mutate_local_profile_preference_section( + p_user_id uuid, + p_local_profile_id text, + p_section text, + p_document_version integer, + p_base_revision bigint, + p_payload jsonb +) RETURNS TABLE ( + accepted boolean, + rejection_reason text, + server_revision bigint, + canonical_section jsonb +) +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = '' +AS $mutation$ +DECLARE + current_row public.local_profile_preferences%ROWTYPE; + current_revision bigint; +BEGIN + IF p_section IS NULL OR p_section NOT IN ('CORE', 'RACK', 'WORKOUT', 'LED', 'VBT') THEN + RETURN QUERY SELECT false, 'UNSUPPORTED_SECTION', 0::bigint, NULL::jsonb; + RETURN; + END IF; + IF p_document_version IS NULL OR p_document_version <> 1 THEN + RETURN QUERY SELECT false, 'UNSUPPORTED_DOCUMENT_VERSION', 0::bigint, NULL::jsonb; + RETURN; + END IF; + IF p_base_revision IS NULL OR p_base_revision < 0 + OR p_payload IS NULL OR jsonb_typeof(p_payload) <> 'object' THEN + RETURN QUERY SELECT false, 'VALIDATION_FAILED', 0::bigint, NULL::jsonb; + RETURN; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM public.local_profiles + WHERE user_id = p_user_id AND id = p_local_profile_id + ) THEN + RETURN QUERY SELECT false, 'UNKNOWN_PROFILE', 0::bigint, NULL::jsonb; + RETURN; + END IF; + + CASE p_section + WHEN 'CORE' THEN + UPDATE public.local_profile_preferences + SET body_weight_kg = (p_payload ->> 'bodyWeightKg')::double precision, + weight_unit = p_payload ->> 'weightUnit', + weight_increment = (p_payload ->> 'weightIncrement')::double precision, + core_revision = core_revision + 1, + core_updated_at = clock_timestamp() + WHERE user_id = p_user_id + AND local_profile_id = p_local_profile_id + AND core_revision = p_base_revision + RETURNING * INTO current_row; + WHEN 'RACK' THEN + UPDATE public.local_profile_preferences + SET equipment_rack = p_payload, + rack_revision = rack_revision + 1, + rack_updated_at = clock_timestamp() + WHERE user_id = p_user_id + AND local_profile_id = p_local_profile_id + AND rack_revision = p_base_revision + RETURNING * INTO current_row; + WHEN 'WORKOUT' THEN + UPDATE public.local_profile_preferences + SET workout_preferences = p_payload, + workout_revision = workout_revision + 1, + workout_updated_at = clock_timestamp() + WHERE user_id = p_user_id + AND local_profile_id = p_local_profile_id + AND workout_revision = p_base_revision + RETURNING * INTO current_row; + WHEN 'LED' THEN + UPDATE public.local_profile_preferences + SET led_color_scheme_id = (p_payload ->> 'ledColorSchemeId')::integer, + led_preferences = p_payload -> 'preferences', + led_revision = led_revision + 1, + led_updated_at = clock_timestamp() + WHERE user_id = p_user_id + AND local_profile_id = p_local_profile_id + AND led_revision = p_base_revision + RETURNING * INTO current_row; + WHEN 'VBT' THEN + UPDATE public.local_profile_preferences + SET vbt_enabled = (p_payload ->> 'vbtEnabled')::boolean, + vbt_preferences = p_payload -> 'preferences', + vbt_revision = vbt_revision + 1, + vbt_updated_at = clock_timestamp() + WHERE user_id = p_user_id + AND local_profile_id = p_local_profile_id + AND vbt_revision = p_base_revision + RETURNING * INTO current_row; + END CASE; + + IF FOUND THEN + canonical_section := public.local_profile_preference_section_canonical(current_row, p_section); + server_revision := (canonical_section ->> 'serverRevision')::bigint; + RETURN QUERY SELECT true, NULL::text, server_revision, canonical_section; + RETURN; + END IF; + + IF p_base_revision = 0 THEN + INSERT INTO public.local_profile_preferences ( + user_id, local_profile_id, + body_weight_kg, weight_unit, weight_increment, core_revision, + equipment_rack, rack_revision, + workout_preferences, workout_revision, + led_color_scheme_id, led_preferences, led_revision, + vbt_enabled, vbt_preferences, vbt_revision + ) VALUES ( + p_user_id, + p_local_profile_id, + CASE WHEN p_section = 'CORE' THEN (p_payload ->> 'bodyWeightKg')::double precision ELSE 0 END, + CASE WHEN p_section = 'CORE' THEN p_payload ->> 'weightUnit' ELSE 'LB' END, + CASE WHEN p_section = 'CORE' THEN (p_payload ->> 'weightIncrement')::double precision ELSE -1 END, + CASE WHEN p_section = 'CORE' THEN 1 ELSE 0 END, + CASE WHEN p_section = 'RACK' THEN p_payload ELSE '{"version":1,"items":[]}'::jsonb END, + CASE WHEN p_section = 'RACK' THEN 1 ELSE 0 END, + CASE WHEN p_section = 'WORKOUT' THEN p_payload ELSE '{"version":1}'::jsonb END, + CASE WHEN p_section = 'WORKOUT' THEN 1 ELSE 0 END, + CASE WHEN p_section = 'LED' THEN (p_payload ->> 'ledColorSchemeId')::integer ELSE 0 END, + CASE WHEN p_section = 'LED' THEN p_payload -> 'preferences' ELSE '{"version":1,"discoModeUnlocked":false}'::jsonb END, + CASE WHEN p_section = 'LED' THEN 1 ELSE 0 END, + CASE WHEN p_section = 'VBT' THEN (p_payload ->> 'vbtEnabled')::boolean ELSE true END, + CASE WHEN p_section = 'VBT' THEN p_payload -> 'preferences' ELSE '{"version":1,"velocityLossThresholdPercent":20,"autoEndOnVelocityLoss":false,"defaultScalingBasis":"MAX_WEIGHT_PR","verbalEncouragementEnabled":false,"vulgarModeEnabled":false,"vulgarTier":"STRONG","dominatrixModeUnlocked":false,"dominatrixModeActive":false}'::jsonb END, + CASE WHEN p_section = 'VBT' THEN 1 ELSE 0 END + ) + ON CONFLICT (user_id, local_profile_id) DO NOTHING + RETURNING * INTO current_row; + + IF FOUND THEN + canonical_section := public.local_profile_preference_section_canonical(current_row, p_section); + server_revision := (canonical_section ->> 'serverRevision')::bigint; + RETURN QUERY SELECT true, NULL::text, server_revision, canonical_section; + RETURN; + END IF; + + CASE p_section + WHEN 'CORE' THEN + UPDATE public.local_profile_preferences + SET body_weight_kg = (p_payload ->> 'bodyWeightKg')::double precision, + weight_unit = p_payload ->> 'weightUnit', + weight_increment = (p_payload ->> 'weightIncrement')::double precision, + core_revision = 1, + core_updated_at = clock_timestamp() + WHERE user_id = p_user_id AND local_profile_id = p_local_profile_id AND core_revision = 0 + RETURNING * INTO current_row; + WHEN 'RACK' THEN + UPDATE public.local_profile_preferences + SET equipment_rack = p_payload, rack_revision = 1, rack_updated_at = clock_timestamp() + WHERE user_id = p_user_id AND local_profile_id = p_local_profile_id AND rack_revision = 0 + RETURNING * INTO current_row; + WHEN 'WORKOUT' THEN + UPDATE public.local_profile_preferences + SET workout_preferences = p_payload, workout_revision = 1, workout_updated_at = clock_timestamp() + WHERE user_id = p_user_id AND local_profile_id = p_local_profile_id AND workout_revision = 0 + RETURNING * INTO current_row; + WHEN 'LED' THEN + UPDATE public.local_profile_preferences + SET led_color_scheme_id = (p_payload ->> 'ledColorSchemeId')::integer, + led_preferences = p_payload -> 'preferences', + led_revision = 1, + led_updated_at = clock_timestamp() + WHERE user_id = p_user_id AND local_profile_id = p_local_profile_id AND led_revision = 0 + RETURNING * INTO current_row; + WHEN 'VBT' THEN + UPDATE public.local_profile_preferences + SET vbt_enabled = (p_payload ->> 'vbtEnabled')::boolean, + vbt_preferences = p_payload -> 'preferences', + vbt_revision = 1, + vbt_updated_at = clock_timestamp() + WHERE user_id = p_user_id AND local_profile_id = p_local_profile_id AND vbt_revision = 0 + RETURNING * INTO current_row; + END CASE; + + IF FOUND THEN + canonical_section := public.local_profile_preference_section_canonical(current_row, p_section); + server_revision := (canonical_section ->> 'serverRevision')::bigint; + RETURN QUERY SELECT true, NULL::text, server_revision, canonical_section; + RETURN; + END IF; + END IF; + + SELECT * INTO current_row + FROM public.local_profile_preferences + WHERE user_id = p_user_id AND local_profile_id = p_local_profile_id; + + IF NOT FOUND THEN + RETURN QUERY SELECT false, 'REVISION_CONFLICT', 0::bigint, NULL::jsonb; + RETURN; + END IF; + + canonical_section := public.local_profile_preference_section_canonical(current_row, p_section); + current_revision := (canonical_section ->> 'serverRevision')::bigint; + RETURN QUERY SELECT false, 'REVISION_CONFLICT', current_revision, canonical_section; +END +$mutation$; + +REVOKE ALL ON FUNCTION public.local_profile_preference_section_canonical( + public.local_profile_preferences, text +) FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION public.mutate_local_profile_preference_section( + uuid, text, text, integer, bigint, jsonb +) FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.local_profile_preference_section_canonical( + public.local_profile_preferences, text +) TO service_role; +GRANT EXECUTE ON FUNCTION public.mutate_local_profile_preference_section( + uuid, text, text, integer, bigint, jsonb +) TO service_role; + +ALTER TABLE public.local_profile_preferences ENABLE ROW LEVEL SECURITY; + +CREATE POLICY local_profile_preferences_owner_select + ON public.local_profile_preferences + FOR SELECT TO authenticated + USING ((select auth.uid()) = user_id); + +CREATE POLICY local_profile_preferences_owner_insert + ON public.local_profile_preferences + FOR INSERT TO authenticated + WITH CHECK ((select auth.uid()) = user_id); + +CREATE POLICY local_profile_preferences_owner_update + ON public.local_profile_preferences + FOR UPDATE TO authenticated + USING ((select auth.uid()) = user_id) + WITH CHECK ((select auth.uid()) = user_id); + +CREATE POLICY local_profile_preferences_owner_delete + ON public.local_profile_preferences + FOR DELETE TO authenticated + USING ((select auth.uid()) = user_id); + +REVOKE ALL ON TABLE public.local_profile_preferences FROM PUBLIC; +REVOKE ALL ON TABLE public.local_profile_preferences FROM anon; +REVOKE ALL ON TABLE public.local_profile_preferences FROM authenticated; +GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.local_profile_preferences TO service_role; + +COMMIT; diff --git a/docs/qa/fixtures/profile-schema42/vitruvian_preferences.xml b/docs/qa/fixtures/profile-schema42/vitruvian_preferences.xml new file mode 100644 index 000000000..807283416 --- /dev/null +++ b/docs/qa/fixtures/profile-schema42/vitruvian_preferences.xml @@ -0,0 +1,46 @@ + + + KG + + + + [{"id":"fixture-vest","name":"Fixture Vest","category":"WEIGHTED_VEST","weightKg":10.0,"behavior":"ADDED_RESISTANCE","enabled":true,"sortOrder":7,"createdAt":1700000000000,"updatedAt":1700000001000}] + + + + + + BOTTOM + + + + + + + + + + + + {"workoutModeId":2,"weightPerCableKg":24.0,"weightChangePerRep":1.0,"eccentricLoadPercentage":110,"echoLevelValue":2,"stallDetectionEnabled":false,"repCountTimingName":"BOTTOM","restSeconds":90} + + {"exerciseId":"fixture-press","setReps":[8,8,6],"weightPerCableKg":30.0,"setWeightsPerCableKg":[30.0,32.5,35.0],"progressionKg":2.5,"setRestSeconds":[60,75,90],"workoutModeId":0,"eccentricLoadPercentage":100,"echoLevelValue":2,"duration":0,"isAMRAP":false,"perSetRestTime":true,"defaultRackItemIds":["fixture-vest"]} + + + + + + + + ESTIMATED_1RM + + + MIX + + + + phoenix-fixture + + + + diff --git a/docs/qa/profile-release-readiness-2026-07-12.md b/docs/qa/profile-release-readiness-2026-07-12.md new file mode 100644 index 000000000..50c279eb5 --- /dev/null +++ b/docs/qa/profile-release-readiness-2026-07-12.md @@ -0,0 +1,255 @@ +# Profile release-readiness evidence — validated 2026-07-13 + +## Verdict + +- Execution status: **DONE**. +- Release verdict: **READY**. +- Automated gates and TalkBack-validated code revision: **PASS** at `361677c22e24e8c0997f519b55920197e5f0d146`. +- Offline schema 42 → 43 named-snapshot replay: **PASS**. +- Manual matrix: **eight rows pass**. Row 2 now has direct TalkBack focus, click, long-click, resulting-screen, and app haptic-request evidence. +- Fresh no-trainer runtime substitution: **PASS**, 20 tests, 0 failures, 0 errors, 0 skipped. +- Fresh release-boundary verification: **PASS**, with seven forbidden markers absent from three release manifests and 705 entries in one release APK. +- Branch-introduced Gradle formatting violation: **FIXED** by follow-up commit `31a4e761febe5faa5a8af04542d7cbca53091219`; `spotlessKotlinGradleCheck` passes. The repository-wide `spotlessCheck` still exposes the known 192-file Kotlin-formatting baseline; it is not reported as passing. + +The previously missing TalkBack proof was completed on the current branch APK. TalkBack visibly focused the fourth bottom item and spoke `Profile, Tab, 4 of 5`; its focused node exposed both `ACTION_CLICK` and `ACTION_LONG_CLICK` plus the hints `Press Alt + ENTER to Profile` and `Press Alt + Shift + ENTER to Open profile switcher`. TalkBack then executed both actions on that same focused node: normal activation opened Profile, and long activation opened the Profiles bottom sheet. Vibrator-manager history independently records the app UID requesting and completing `HEAVY_CLICK`. A physical Android device and compatible trainer were unavailable, so personally heard/felt output and live trainer telemetry remain unavailable rather than inferred. + +## Scope and provenance + +- Working branch: `codex/profile-readiness-fixtures`. +- Pre-report validation revision: `361677c22e24e8c0997f519b55920197e5f0d146` (`docs: correct profile readiness gate evidence`). +- Acceptance-code HEAD: `8103281babd6484a3c6aa1471c672dbfeff7a842` (`fix: use localized Insights navigation semantics`). +- Post-review automation HEAD: `31a4e761febe5faa5a8af04542d7cbca53091219` (`test: enforce profile QA release boundary`). Its production-source delta is documentation-only KDoc; the remaining changes add a shared seven-marker inventory, a hermetic unit contract, a release-output verifier, and Gradle-line-ending normalization. +- Earlier matrix HEAD used for regression-unaffected rows 4–7: `a9028cade2cbfbeacf405a746e40c4f6e44bdb22`. +- The app-code delta from `a9028ca` to `8103281b` is limited to localized Insights bottom-navigation semantics plus its contract test; rows 4–7 do not exercise that delta. Rows 1, 3, and 8 were rerun with the `8103281b` APK. Row 2 was rerun with the current-branch `361677c2` APK. +- TalkBack APK package: `com.devil.phoenixproject.debug`, version code `5`, version name `0.9.5-DEBUG`. +- Acceptance-code evidence root: `C:\Users\dasbl\AndroidStudioProjects\Project-Phoenix-MP\.phoenix-review\task4\final-transcripts`. +- Earlier regression-unaffected matrix root: `C:\Users\dasbl\AndroidStudioProjects\Project-Phoenix-MP\.phoenix-review\profile-readiness\task4-final\matrix`. +- Current TalkBack APK build/install provenance root: `C:\Users\dasbl\AndroidStudioProjects\Project-Phoenix-MP\.phoenix-review\profile-readiness\talkback-followup` (`build-provenance.txt` and `install-and-launch.txt`). +- Current TalkBack acceptance root: `C:\Users\dasbl\AndroidStudioProjects\Project-Phoenix-MP\.phoenix-review\profile-readiness\talkback-host-followup\host-critical-2`. +- Fresh current-revision automated-gate root: `C:\Users\dasbl\AndroidStudioProjects\Project-Phoenix-MP\.worktrees\profile-readiness\build\phoenix-review\profile-readiness\automated-gates-361677c2`. + +All emulator validation was local and offline. Airplane mode was enabled and Wi-Fi was disabled before install/launch. The upgrade replay also had mobile data disabled before install. On the fresh matrix, airplane mode already prevented connectivity during install; all mobile-data keys were explicitly set to `0` and the active network was `none` before first launch. + +No Supabase CLI/MCP/Dashboard/remote action was performed. + +The final TalkBack run used the current-branch debug APK, length `40,998,548` bytes and SHA-256 `2fd45d02b07374f2be6ecaa48a2cb90ab05fa7bd2ff95c8cf8393a603f8625c2`. The raw build capture records branch, full HEAD, command, status, bytes, and hash in `talkback-followup/build-provenance.txt`; `install-and-launch.txt` records install success, package version, and disabled network radios. TalkBack was version `16.0.0.738667889` on the disposable API-36 AVD. The device remained in airplane mode with Wi-Fi, mobile data, and Bluetooth disabled. + +## Acceptance-code HEAD APK build + +The definitive build command was: + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :androidApp:assembleDebug --rerun-tasks --console=plain +``` + +It ran at acceptance-code HEAD `8103281b`, exited `0`, and reported `BUILD SUCCESSFUL in 2m 14s` with 74 tasks executed. The resulting APK is `androidApp-debug-8103281b.apk`, length `40,998,548` bytes, SHA-256: + +```text +d150cc8deeefee313cbecbaa847f60d5187360943b1a33266d0a2eb8b2f9a072 +``` + +Complete command metadata, standard output, standard error, exit capture, APK badging, and hashes are in `build.metadata.txt`, `build.stdout.txt`, `build.stderr.txt`, `build.exit.txt`, `build.apk-badging.txt`, and `build.sha256.txt`. Two earlier wrapper attempts failed before Gradle because the wrapper lacked `JAVA_HOME` and then `ANDROID_HOME`; their complete captures remain as `build-attempt1-harness.*` and `build-attempt2-env.*`. They are not presented as build attempts that compiled source. + +Key hashes from `build.sha256.txt`: + +| Artifact | SHA-256 | +| --- | --- | +| Acceptance-code APK | `d150cc8deeefee313cbecbaa847f60d5187360943b1a33266d0a2eb8b2f9a072` | +| Build stdout | `4aa48b85448e7607e8487d8f699389f7de75f51c7a12e6faeb80e6c3d5974f79` | +| Build stderr | `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855` | +| Build exit capture | `271e34725eb16539a3eae5218bdde30854e4e91faa5e83662370fcb04ddc2813` | + +## Actual named-snapshot replay and upgrade + +The immutable source AVD was never launched or mutated. The first disposable clone copied the original `phoenix-schema42-v1` snapshot byte-for-byte and attempted: + +```text +-snapshot phoenix-schema42-v1 -force-snapshot-load -no-snapshot-save +``` + +The emulator rejected that copied snapshot with `different AVD configuration`, deleted only the disposable clone's copied tag, and exited. The failed attempt, exact command, output, bounded ADB checks, cleanup, and unchanged source hashes are preserved under `snapshot-attempt1/`. + +The fallback used a second disposable clone. It cold-booted with snapshot loading and saving disabled, stayed offline, and reproduced the immutable stopped app data exactly: + +| Artifact | Immutable and pre-save SHA-256 | +| --- | --- | +| Schema-42 database | `1fe5af46d8b87e0c9eed56e1ad568f7a18f3659d388fcdf0be389d9d309dffc3` | +| Legacy preferences XML | `a985f45159b1d383992210ce0464e7f06a7de598ba20a2633f6ce0f3cedc2cf3` | + +The fallback then saved the exact tag with `adb -s emulator-5560 emu avd snapshot save phoenix-schema42-v1`, shut down, and relaunched with: + +```text +-snapshot phoenix-schema42-v1 -force-snapshot-load -no-snapshot-save +``` + +The emulator reported `Successfully loaded snapshot 'phoenix-schema42-v1' using 3887 ms`; ADB was online and `boot_completed=1` on the first poll. The database and XML still matched the immutable hashes before install. The saved snapshot's state payload files remained unchanged across replay; the emulator rewrote only `snapshot.pb` metadata during load, recorded explicitly in `named-snapshot-replay/snapshot-replay-integrity.txt`. + +The acceptance-code APK was installed with `adb install -r`. Install output was `Success`, `firstInstallTime` remained `2026-07-12 20:18:39`, and the database/XML remained byte-identical before first launch. The first migration-gate poll observed the completion flag. Stopped inspection then showed: + +```text +user_version=43 +profile_count=1 +preference_count=1 +``` + +The one existing Default profile had one `schema_version=1`, `legacy_migration_version=1` row containing the legacy 82.5 kg core values, Fixture Vest rack, workout document, LED scheme, and enabled VBT document. Evidence is in `named-snapshot-replay/install-r.*`, `postinstall-prelaunch-hashes.txt`, `migration-gate-result.txt`, `postmigration-before-new-profile-query.txt`, and the captured stopped DB/XML files. + +Migration idempotence is also retained in the earlier regression-unaffected upgrade evidence under `C:\Users\dasbl\AndroidStudioProjects\Project-Phoenix-MP\.phoenix-review\profile-readiness\task4-final\upgrade`: `normalized-first.txt` and `normalized-second.txt` have the same normalized projection SHA-256, `3b33672d6ac7606bc34a840ee9cd352a49ff33efff099b5d65f2a19b59dc04a1`, and the complete `UserProfilePreferences` row compares equal between `postmigration-first.db` and `postmigration-second.db`. The only intervening app-code change was the Insights content description; it did not touch migration or persistence. + +### New product-default profile before any seed + +After the upgraded app reached a Ready Home context, the run opened the Profile switcher and created `freshdefaults` through the app UI. Repository-backed stopped inspection found one new active profile, ID `18dfe175-1169-48a8-97c9-44c8835dd55c`, with all product defaults and no inherited fixture values: + +| Section | New-profile persisted value | +| --- | --- | +| Schema/migration | `schema_version=1`, `legacy_migration_version=1` | +| Core | `0.0`, `LB`, increment `-1.0` | +| Equipment rack | `{"version":1,"items":[]}` | +| Workout | `{"version":1}` | +| LED | scheme `0`, `{"version":1}` | +| VBT | enabled `1`, `{"version":1}` | +| Per-section metadata | updated/generation/server revision `0`; dirty `1` | +| Local safety | safe word, calibrated, adult-confirmed, and adult-prompted keys all absent | + +The query also proves absence of Fixture Vest, the legacy single-exercise document, and legacy VBT content. The UI remained Ready with `freshdefaults` active. Evidence is `new-profile-defaults/ui-new-profile-ready.xml`, `ui-new-profile-profile-top.xml`, `ui-defaults-sweep-*.xml`, `new-profile-defaults-query.txt`, and `after-create.db`; the query's final assertion is `all_product_defaults_pass=True`. + +## Fresh acceptance-code 320dp seeded matrix + +The acceptance-code APK was installed on a wiped Pixel 6 API-36 clone at 1080 × 2400 with density override 540, exactly 320dp wide. Before first launch, airplane mode was `1`, Wi-Fi was disabled, `mobile_data`, `mobile_data1`, and `mobile_data2` were `0`, and the active network was `none`. + +Before and after the seed, the exact icon-only bottom order was: + +```text +Analytics → Insights → Home → Profile → Settings +``` + +The debug-only broadcast produced `ProfileQaSeed: PROFILE_QA_SEED_OK` on the first poll. Evidence is `matrix-8103281b/preseed-home.xml`, `postseed-home.xml`, `seed-broadcast.*`, `seed-poll.txt`, and `seed-result.txt`. + +```powershell +adb -s emulator-5562 shell am broadcast -a com.devil.phoenixproject.QA_SEED_PROFILE -p com.devil.phoenixproject.debug +``` + +The run switched A → B and opened B's rack management surface. It displayed `QA Assistance B`, `Assistance - Counterweight`, `16.53 lb`, `Enabled`. It then switched B → A, opened A's rack, and displayed `QA Weighted Vest A`, `Weighted vest - Added resistance`, `10 kg`, `Enabled`. + +The complete post-B A sweep restored all inspected A values: 82.5 kg body weight, kg unit, 2.5 kg increment, 15s/7s/45s timing, top rep timing, audio/countdown/auto-start/motion/scaling choices, Green LED scheme, enabled VBT with 20%/Estimated 1RM, verbal/vulgar/dominatrix state, and local safety `Phoenix Alpha`, calibrated, adults-only confirmed. The stopped database and preferences XML independently preserve the full A/B documents and safety keys. + +Evidence: `matrix-8103281b/profile-b-rack-route.xml`, `restored-a-rack-route.xml`, `restored-a-sweep-1.xml` through `restored-a-sweep-10.xml`, `restored-a-sweep-combined.txt`, `seeded-final-db-query.txt`, `seeded-final-preferences.xml`, and `seeded-final-vitruvian.db`. + +## Manual acceptance matrix + +| # | Result | Evidence and observation | +| --- | --- | --- | +| 1 | **PASS** | Acceptance-code fresh 320dp captures render exactly Analytics → Insights → Home → Profile → Settings, with Home selected and no clipping. Evidence: `matrix-8103281b/preseed-home.xml` and `postseed-home.xml`. | +| 2 | **PASS** | With TalkBack 16.0 and touch exploration enabled, a real host `Alt+Right` chord reached Android's hardware keyboard path and produced TalkBack focus events. `05-profile-focused.png` visibly frames the fourth tab; `05-profile-focus-logcat.txt` identifies the focused node as `Profile`, `clickable`, `longClickable`, with `ACTION_CLICK` and `ACTION_LONG_CLICK`, speaks `Profile, Tab, 4 of 5`, and announces both action hints. This emulator's host path did not forward the Alt modifier for `Alt+Enter`, so the action chords were delivered through Android's AOSP `uinput` utility as a temporary `KEYBOARD | EXTERNAL` USB keyboard. `12-uinput-alt-enter-logcat.txt` records `CLICK_CURRENT`, successful `ACTION_CLICK`, and `TYPE_VIEW_CLICKED`; `12-profile-opened.png` shows Profile. `13-uinput-alt-shift-enter-logcat.txt` records `LONG_CLICK_CURRENT` and successful `ACTION_LONG_CLICK`; `13-profile-switcher.png` shows the Profiles sheet. `13-vibrator-after.txt` adds an app-UID `HEAVY_CLICK` absent before the action. Full method and file index: `host-critical-2/RESULTS.md`. | +| 3 | **PASS** | Acceptance-code A → B showed B's counterweight rack; B → A showed A's weighted vest and restored the complete A core, rack, workout, LED, VBT/verbal, and local-safety state. Evidence: the fresh rack routes, ten-file A sweep, final DB query, and preferences XML listed above. | +| 4 | **PASS** | Regression-unaffected matrix evidence shows A selecting `Bench Press (Bar)` with an empty/no-estimate state, B retaining `Bench Press (Bench)` with its own 77-per-cable velocity result, three highlights, and five history rows, then A restoring Bar without B metrics. Correct evidence: `a-restored-bench2.xml`, `profile-b-selection-isolation.xml`, `profile-a-selection-restored.xml`, `profile-a-history.xml`, `profile-b-history.xml`, `history-counts.txt`, and `insights-db.txt`. | +| 5 | **PASS** | Regression-unaffected evidence covers rename, Default's absent Delete action, guarded destructive confirmation, deletion, and reassignment of B's sessions/PRs/assessment/velocity estimate to Default. Evidence: `profile-renamed.xml`, `default-profile-top.xml`, `delete-dialog-b.xml`, `switcher-after-delete.xml`, and `postdelete-reassignment.txt`. | +| 6 | **PASS** | Profile exposes management entries for Equipment Rack and Achievements, while Settings omits both and retains its global groups. Set Ready's existing workout-scoped rack selection/management access is intentionally preserved; this row does not claim Profile is the only rack route. Evidence: `equipment-rack-route.xml`, `achievements-route.xml`, `profile-a-top.xml`, `profile-a-mid.xml`, and `settings-*.xml`. | +| 7 | **PASS** | Home edge gestures and Just Lift expose no profile selector. B's VBT is off while historical assessment, velocity, and five-session information remain visible. Live no-trainer behavior is covered by the 20-test runtime suite below. Evidence: `home-edge-gestures.xml`, `just-lift.xml`, `profile-b-vbt.xml`, `profile-b-top.xml`, and `profile-b-history.xml`. | +| 8 | **PASS** | The actual named tag was force-replayed offline, the acceptance-code APK upgraded via `install -r`, the migration gate completed with one normalized row, Ready rendered, and a UI-created second profile persisted every product default without inherited legacy/safety values. Evidence: `snapshot-attempt1/`, `named-snapshot-replay/`, and `new-profile-defaults/`. | + +No `Switch Profile` or `Profiles` content selector appeared in Home or Just Lift. The Profiles root sheet was directly confirmed from a TalkBack long action on the focused Profile tab. + +## Runtime substitution and hardware bounds + +No compatible Vitruvian trainer was present. The no-trainer substitution command was freshly rerun at pre-report revision `361677c2`: + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest --tests "*VerbalEncouragementPreferenceCascadeTest" --tests "*SafeWordDetectionManagerTest" --tests "*AdultModePresentationTest" --tests "*VbtEnabledRuntimeTest" --rerun-tasks --console=plain +``` + +It exited `0` and reported `BUILD SUCCESSFUL in 4m 10s`. Fresh XML totals are: + +| Suite | Tests | Failures | Errors | Skipped | +| --- | ---: | ---: | ---: | ---: | +| `VerbalEncouragementPreferenceCascadeTest` | 11 | 0 | 0 | 0 | +| `SafeWordDetectionManagerTest` | 2 | 0 | 0 | 0 | +| `AdultModePresentationTest` | 3 | 0 | 0 | 0 | +| `VbtEnabledRuntimeTest` | 4 | 0 | 0 | 0 | +| **Total** | **20** | **0** | **0** | **0** | + +The fresh command, complete native output, XML totals, and exit are in `automated-gates-361677c2/runtime-substitution-transcript.txt` and `runtime-substitution-native-output.txt`. The earlier acceptance-code streams, metadata, copied XML, totals, and hashes remain under `runtime-20-tests/`. + +No physical Android device was connected. A person hearing the label and physically feeling the haptic remain **UNAVAILABLE** claims. The emulator evidence does prove TalkBack's exact speech output, the resulting click/long-click app states, and the app-originated `HEAVY_CLICK` request. Live trainer telemetry is likewise **UNAVAILABLE** and remains covered by the reviewed no-trainer substitution suite. + +## Fresh current-revision automated release gates + +All required and focused non-interactive gates below were freshly run at pre-report revision `361677c2`. Test rows overlap by design, so their counts are reported independently rather than summed. + +| Gate | Fresh result | +| --- | --- | +| New QA contracts | 18 tests; 0 failures, errors, or skips | +| Repository regressions | 38 tests; 0 failures, errors, or skips | +| Profile/navigation suite | 32 tests; 0 failures, errors, or skips | +| Profile screen/preferences/runtime | 109 tests; 0 failures, errors, or skips | +| Settings/profile persistence | 158 tests; 0 failures, errors, or skips | +| Ownership/navigation | 34 tests; 0 failures, errors, or skips | +| SQLDelight generation/migration/manifest | **PASS**; schema 43, 389 columns, 46 tables | +| Schema/migration tests | 49 tests; 0 failures, errors, or skips | +| Sync/transport/privacy | 206 tests; 0 failures, errors, or skips | +| Repository race/migration | 57 tests; 0 failures, errors, or skips | +| Koin graph | 1 test; 0 failures, errors, or skips | +| Full shared Android-host suite | 2,822 tests; 0 failures, errors, or skips; no skipped Profile suites | +| Android shared-main and iOS main/test compilation | **PASS** | +| Android debug unit, lint, debug APK, release APK | 49 tests with no failures/errors/skips; lint and both packages **PASS** | +| Standalone Profile navigation contract | 8 tests; 0 failures, errors, or skips | +| No-trainer runtime substitution | 20 tests; 0 failures, errors, or skips | + +Preflight also passed its 256-source inventory, and the local-only/privacy audit confirmed the tracked handoff, complete DI, sole profile-switcher ownership in `ProfileSwitcherViewModel`, absent legacy selectors and service-role material, and zero portal targets. + +Commit `31a4e761` adds `verifyQaReleaseBoundary`, an explicit Gradle verification task that depends on `assembleRelease`, fails when the release APK or merged manifests are absent, and consumes the same tracked seven-marker inventory as the hermetic unit contract. The final isolated command was: + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' '-Pversion.code=999999' :androidApp:verifyQaReleaseBoundary --rerun-tasks --console=plain +``` + +It rebuilt the release app and exited `0`. The independent release audit scanned the same seven forbidden markers across three release manifests and 705 entries in one release APK, finding zero leaks. The unsigned APK was 17,055,838 bytes, SHA-256 `c757472f734daf6c9188553106cd5f7c63cec0a21a5559cc5fd97e2443e0519a`, with verified `versionCode=999999`. The combined pinned build's debug APK was 40,998,548 bytes, SHA-256 `60a59ee17b1693d2ce4c274c33179e7fa9c0e97e40490594bd53fe6b93d9eef8`, also with `versionCode=999999`; it is distinct from the version-code-5 APK used for TalkBack acceptance above. + +The first orchestration script had a stale expected count of 17 new QA tests and stopped after Gradle correctly passed 18; the run resumed with the assertion corrected in memory. Its outer one-hour ceiling later expired with exit `124` only after Android unit, lint, both packages, and the independent release scan had logged **PASS**, while entering full Spotless. No required product gate was lost: Spotless and `verifyQaReleaseBoundary` were then run as isolated commands, producing their definitive exits. + +Evidence is under `automated-gates-361677c2/`: `task5-361677c2-fresh-transcript.txt`, `task5-361677c2-resumed-transcript.txt`, `verify-qa-release-boundary-transcript.txt`, `profile-navigation-contract-transcript.txt`, `runtime-substitution-transcript.txt`, `runtime-substitution-native-output.txt`, and `final-audit.txt`. The boundary transcript records the exact command and exit `0`; the resumed harness transcript independently records `RELEASE QA AUDIT PASS manifests=3 apks=1 entries=705 leaks=0`. + +## Spotless result and non-blocking baseline provenance + +The historical Task 4 non-mutating command was: + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' spotlessCheck --console=plain +``` + +It ran at exact acceptance HEAD, exited `1`, and reported `BUILD FAILED in 15s`. The first failing task was `spotlessKotlinGradleCheck`; its sole reported file was `androidApp/build.gradle.kts`, with LF endings in source lines **333–338**: + +```text +333: testImplementation(libs.koin.test) +334: testImplementation(libs.koin.test.junit4) +335: testImplementation(libs.ktor.client.mock) +336: testImplementation(libs.multiplatform.settings) +337: testImplementation(libs.multiplatform.settings.test) +338: } +``` + +Full Task 4 stdout, stderr, exit, metadata, the numbered source excerpt, and hashes are under `spotless/`. `spotless.stderr.txt` SHA-256 is `6dcbcad769728ef4b7e875d94c2e672bfa9346f25d956bd8de237fc35c04347e`; the exit capture SHA-256 is `f1b2f662800122bed0ff255693df89c4487fbdcf453d3524a42d4ec20c3d9c04`. No `spotlessApply` command ran during Task 4, and that run left source state clean. + +The earlier wording called this violation pre-existing. That was inaccurate: the readiness range added three test dependencies with LF endings to a CRLF working-tree tail, producing the mixed six-line hunk. Follow-up commit `31a4e761` normalized `androidApp/build.gradle.kts`; the fresh command below passes: + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :spotlessKotlinGradleCheck --rerun-tasks --console=plain +``` + +The fresh `:spotlessKotlinGradleCheck --rerun-tasks` exits `0`. Full `spotlessCheck --rerun-tasks` proceeds beyond that corrected Gradle task but exits `1`; the current inventory is 192 Kotlin files. It remains a real failed command and is not relabeled as passing. The prior branch-protection audit listed Unit Tests, Lint Check, iOS Schema Sync Check, iOS Test Target Compile, and Build Android as required check contexts; it did not list full `spotlessCheck`. This is narrower than claiming no repository automation invokes Spotless: the repository also contains a Security Hygiene workflow job and local agent-quality scripts. The formatting baseline is tracked as separate process debt rather than a blocker in the audited branch-protection set. Fresh exits and the inventory are recorded in `automated-gates-361677c2/spotless-kotlin-gradle-check-transcript.txt`, `spotless-check-transcript.txt`, and `final-audit.txt`. A historical diagnostic `spotlessApply` briefly materialized the mechanical rewrite; all unrelated formatter-only edits were reverted before commit rather than mixing a 192-file style migration into this feature branch. + +## Cleanup and immutability + +All disposable AVDs were shut down and removed only after absolute-path checks kept deletion inside `C:\Users\dasbl\.android\avd`. Final ADB inventory was empty. The immutable source snapshot hashes before and after all replay/matrix work are identical: + +| Source payload | SHA-256 | +| --- | --- | +| `hardware.ini` | `d2f55bbb9fc06476293c2460b9e743bd40ba0fd70e7bc13ec9a3155c10f03938` | +| `ram.bin` | `eabce3b62cacaa5feff842e6e26484759db4a4ec1f41c31998cf952b4b2aeda9` | +| `screenshot.png` | `8a9f899b79141dcbc9a39a2e66ba2d8710a3aae5ac46db3242d0d2084a6fc480` | +| `snapshot.pb` | `53c714141ddad2efcae4884cec0d1a02e87ec498b5693ff86f8063f6e10228a9` | +| `textures.bin` | `9da9d94e2e86016384b8b9647e4af8f70fa25381ccc4c9366c8be5ffc8777b5b` | + +Evidence: `immutable-source-before.sha256.txt`, `immutable-source-after.sha256.txt`, `immutable-source-compare.txt`, the two replay cleanup files, `matrix-8103281b/clone-delete-audit.txt`, and the final TalkBack run's `15-delete-audit.txt`, `15-avds-after.txt`, `15-adb-after.txt`, and `15-immutable-after-sha256.txt`. + +Before this final validation update, `git status --short` was empty at branch HEAD `361677c22e24e8c0997f519b55920197e5f0d146`. Historical acceptance-code runtime and Spotless commands temporarily detached the already-isolated worktree at `8103281b`, left source state clean, and restored the branch. The fresh current-revision automation left the index clean, passed worktree/range whitespace checks, changed no source file, and saw only this in-progress tracked Markdown update. External automated transcripts, TalkBack transcripts, screenshots, AOSP input event files, and the rewritten `.superpowers/sdd/task-4-report.md` remain ignored evidence. diff --git a/docs/qa/profile-schema42-fixture.md b/docs/qa/profile-schema42-fixture.md new file mode 100644 index 000000000..75a674fd7 --- /dev/null +++ b/docs/qa/profile-schema42-fixture.md @@ -0,0 +1,128 @@ +# Profile schema-42 Android fixture + +This recipe materializes the pre-profile database and legacy preference input used to prove the schema-42 to current-schema upgrade. The fixture source is commit `ac84d9bb8e156002833ad526bf324a8f12710da0`; do not substitute a moving branch or shortened SHA. + +All commands below are PowerShell commands run from the repository root. They use only installed Android SDK components and Gradle's existing offline cache. They must not contact Supabase or any other network service. + +## Create the schema-42 build + +Create the isolated worktree and make only this version edit in `shared/build.gradle.kts`: + +```powershell +git check-ignore -q .worktrees/profile-schema42 +git worktree add .worktrees/profile-schema42 -b codex/schema-42-fixture ac84d9bb8e156002833ad526bf324a8f12710da0 +Set-Location .worktrees/profile-schema42 +``` + +```diff +- // Version 41 = initial schema (1) + 40 migrations (1.sqm through 40.sqm). +- version = 41 ++ // Version 42 = initial schema (1) + 41 migrations (1.sqm through 41.sqm). ++ version = 42 +``` + +The fixture branch must not contain `shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/migrations/42.sqm`. + +```powershell +$env:JAVA_HOME = 'C:\Users\dasbl\AppData\Local\Programs\Android Studio\jbr' +$env:ANDROID_HOME = 'C:\Users\dasbl\AppData\Local\Android\Sdk' +if (Test-Path shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/migrations/42.sqm) { throw '42.sqm must be absent' } +.\gradlew.bat '-Pskip.supabase.check=true' --offline :shared:generateCommonMainVitruvianDatabaseInterface :shared:verifyCommonMainVitruvianDatabaseMigration :shared:validateSchemaManifest :androidApp:assembleDebug --rerun-tasks --console=plain +``` + +## Create the disposable API-36 AVD + +Use the installed Google Play x86_64 image and keep the AVD disposable. If this AVD name already exists, delete it first rather than reusing unknown state. + +```powershell +$sdk = 'C:\Users\dasbl\AppData\Local\Android\Sdk' +$avd = 'phoenix-schema42-api36' +$package = 'com.devil.phoenixproject.debug' +& "$sdk\cmdline-tools\latest\bin\avdmanager.bat" create avd --name $avd --package 'system-images;android-36;google_apis_playstore;x86_64' --device 'pixel_6' --force +& "$sdk\emulator\emulator.exe" -avd $avd -wipe-data -no-snapshot-load -no-boot-anim -dns-server 127.0.0.1 +``` + +After Android boots, explicitly disable radios and install the fixture APK: + +```powershell +$adb = "$sdk\platform-tools\adb.exe" +& $adb wait-for-device +& $adb shell svc wifi disable +& $adb shell svc data disable +$apk = (Resolve-Path 'androidApp/build/outputs/apk/debug/androidApp-debug.apk') +& $adb install $apk +& $adb shell monkey -p $package -c android.intent.category.LAUNCHER 1 +& $adb shell am force-stop $package +``` + +## Inject the tracked legacy preferences and save the snapshot + +The app must launch once before injection so that its schema-42 database and private data directory exist. Inject while it is stopped, then save the named snapshot. + +```powershell +$xml = (Resolve-Path '..\profile-readiness\docs\qa\fixtures\profile-schema42\vitruvian_preferences.xml') +& $adb push $xml /data/local/tmp/vitruvian_preferences.xml +& $adb shell run-as $package mkdir -p shared_prefs +& $adb shell run-as $package cp /data/local/tmp/vitruvian_preferences.xml shared_prefs/vitruvian_preferences.xml +& $adb shell run-as $package chmod 600 shared_prefs/vitruvian_preferences.xml +& $adb shell run-as $package cat shared_prefs/vitruvian_preferences.xml +& $adb emu avd snapshot save phoenix-schema42-v1 +& $adb emu avd snapshot list +``` + +Never uninstall the app and never run `pm clear` between fixture creation and the upgrade. Either action destroys the state this fixture is designed to exercise. + +## Record checksums and immutable evidence + +Store all generated evidence outside Git's tracked fixture inputs: + +```powershell +$evidence = 'C:\Users\dasbl\AndroidStudioProjects\Project-Phoenix-MP\.phoenix-review\profile-readiness\schema42' +New-Item -ItemType Directory -Force $evidence | Out-Null +git rev-parse HEAD | Set-Content "$evidence\fixture-commit.txt" +Get-FileHash $apk -Algorithm SHA256 | Format-List | Out-File "$evidence\apk-sha256.txt" +Get-FileHash $xml -Algorithm SHA256 | Format-List | Out-File "$evidence\xml-sha256.txt" +& "$sdk\build-tools\36.0.0\apksigner.bat" verify --print-certs $apk | Out-File "$evidence\apk-signer.txt" +& $adb version | Out-File "$evidence\adb-version.txt" +& "$sdk\emulator\emulator.exe" -version 2>&1 | Out-File "$evidence\emulator-version.txt" +& $adb shell getprop ro.build.version.sdk | Out-File "$evidence\api.txt" +& $adb shell getprop ro.product.cpu.abi | Out-File "$evidence\abi.txt" +& $adb emu avd name | Out-File "$evidence\avd-name.txt" +Get-ChildItem "$env:USERPROFILE\.android\avd\$avd.avd\snapshots\phoenix-schema42-v1" -File -Recurse | + Get-FileHash -Algorithm SHA256 | + Sort-Object Path | + Format-Table -AutoSize | + Out-File "$evidence\snapshot-sha256.txt" +``` + +Shut down the fixture emulator after the snapshot is saved. Restore immutably by launching that snapshot with snapshot writes disabled: + +```powershell +& $adb emu kill +& "$sdk\emulator\emulator.exe" -avd $avd -snapshot phoenix-schema42-v1 -no-snapshot-save -no-boot-anim -dns-server 127.0.0.1 +& $adb wait-for-device +``` + +## Upgrade in place and inspect SQL + +Install the current debug APK with `-r` so Android retains the schema-42 database and injected SharedPreferences. Again, do not uninstall and do not use `pm clear`. + +```powershell +$upgradeApk = (Resolve-Path '..\profile-readiness\androidApp\build\outputs\apk\debug\androidApp-debug.apk') +& $adb install -r $upgradeApk +& $adb shell monkey -p $package -c android.intent.category.LAUNCHER 1 +$migrationDeadline = [DateTime]::UtcNow.AddSeconds(60) +$migrationReady = $false +do { + $migrationLine = & $adb shell run-as $package grep -F profile_preferences_legacy_migration_complete_v1 shared_prefs/vitruvian_preferences.xml 2>$null + $migrationReady = $LASTEXITCODE -eq 0 -and $migrationLine -match 'value="true"' + if (-not $migrationReady) { Start-Sleep -Seconds 1 } +} while (-not $migrationReady -and [DateTime]::UtcNow -lt $migrationDeadline) +if (-not $migrationReady) { throw 'Timed out after 60 seconds waiting for required profile preference migration' } +& $adb shell am force-stop $package +& $adb shell run-as $package sqlite3 databases/vitruvian.db 'PRAGMA user_version;' +& $adb shell run-as $package sqlite3 databases/vitruvian.db 'SELECT profile_id, legacy_migration_version, body_weight_kg, weight_unit, weight_increment, led_color_scheme_id, equipment_rack_json, workout_preferences_json, vbt_preferences_json FROM UserProfilePreferences ORDER BY profile_id;' +& $adb shell run-as $package cat shared_prefs/vitruvian_preferences.xml +``` + +The post-upgrade database must be on the current schema, each existing profile row must have `legacy_migration_version = 1`, and the sentinel values from the tracked XML must appear in the corresponding profile preference sections. diff --git a/docs/superpowers/plans/2026-07-11-profile-preferences-data-foundation.md b/docs/superpowers/plans/2026-07-11-profile-preferences-data-foundation.md new file mode 100644 index 000000000..c09a911c9 --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-profile-preferences-data-foundation.md @@ -0,0 +1,3598 @@ +# Profile Preferences Data Foundation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make every person-specific training preference profile-scoped, migration-safe, locally authoritative, and ready for the separate sync and Profile UI plans. + +**Architecture:** SQLDelight schema 43 stores a one-to-one preference aggregate with five independently versioned sections, while profile-prefixed multiplatform Settings stores safety and consent values that must never sync or enter backups. `UserProfileRepository` remains the public active-profile facade and delegates typed persistence to focused preference and safety stores; an awaited startup gate copies the legacy global snapshot before any profile-sensitive consumer can run. Existing managers keep a compatibility `UserPreferences` view while their setters and runtime reads are moved to the active profile. + +**Tech Stack:** Kotlin Multiplatform, Kotlin coroutines and `StateFlow`, kotlinx.serialization JSON, SQLDelight/SQLite migrations, multiplatform-settings, Koin, Compose Multiplatform, kotlin.test, Android host tests. + +## Global Constraints + +- This plan must land before `2026-07-11-profile-preferences-sync-backend.md` and `2026-07-11-profile-tab-ui-navigation.md`. +- Schema version ends at **43** everywhere: canonical schema, `42.sqm`, Gradle SQLDelight version, fallback statements, reconciliation manifest, generated schema, and parity tests. +- Existing profiles receive one normalized snapshot of legacy values; profiles created later receive product defaults and `legacyMigrationVersion = 1`. +- `vbtEnabled` defaults to `true` for both migrated and new profiles and gates live VBT evaluation/feedback only. +- `discoModeUnlocked` is profile-scoped; transient `discoModeActive` remains BLE runtime state and is never persisted, synchronized, or backed up. +- Each local section edit changes only that section's timestamp, generation, and dirty flag; it does not change the server revision. +- Raw malformed JSON remains stored and diagnostically invalid until an explicit user edit/reset; typed fallback values must never overwrite it automatically. +- `safeWord`, calibration, and adult consent/prompt state use profile-prefixed local keys and never appear in syncable models or backup JSON. +- Legacy global keys remain readable for one release and are never written by a profile-scoped setter. +- Profile deletion remains forbidden for `default`, merges existing profile-owned data deterministically, and journals cross-store cleanup before SQL commit. +- Profile create/activate and switch operations journal the prior active ID (plus a newly created ID when applicable) in the same SQL transaction as activation; normal success clears the journal, while failure/startup reconciliation restores one database-consistent Ready context and removes any failed-create rows. +- `PendingProfileContextRecovery` and `PendingProfileLocalCleanup` are device-local operational journals; neither is synchronized nor included in backup v5. +- Backup schema version 5 excludes local timestamps, generations, dirty flags, server revisions, voice phrase, calibration, and consent. +- Commands assume `$env:JAVA_HOME='C:\Users\dasbl\AppData\Local\Programs\Android Studio\jbr'`; in PowerShell the Gradle property must be quoted as `'-Pskip.supabase.check=true'`. +- Do not apply or deploy backend changes in this plan. + +--- + +## File Structure + +### New files + +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/ProfilePreferences.kt` — typed core, rack, workout, LED, VBT, safety, metadata, validity, and active-context models. +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/ProfilePreferencesCodec.kt` — the only raw JSON encode/decode boundary for profile preference documents. +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/ProfilePreferencesValidator.kt` — deterministic field and nested-document validation. +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/ProfileWorkoutDefaultsMapper.kt` — lossless legacy quick-start/default document mappings shared by migration and runtime consumers. +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/LegacyProfilePreferencesReader.kt` — one-shot legacy snapshot read and field-by-field normalization. +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/ProfileLocalSafetyStore.kt` — profile-prefixed Settings persistence and cleanup. +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/ProfilePreferencesRepository.kt` — focused SQLDelight preference store used by the public profile facade. +- `shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/migrations/42.sqm` — schema 42 to 43 migration and default-row seeding. +- `shared/src/commonTest/kotlin/com/devil/phoenixproject/data/preferences/ProfilePreferencesCodecTest.kt` — document defaults, round trips, validation, and invalid-section isolation. +- `shared/src/commonTest/kotlin/com/devil/phoenixproject/data/preferences/LegacyProfilePreferencesReaderTest.kt` — corrupt legacy normalization coverage. +- `shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightProfilePreferencesRepositoryTest.kt` — section updates, generations, invalid raw retention, and profile isolation. +- `shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/migration/ProfilePreferencesMigrationTest.kt` — required migration, retries, reconciliation, and boot-gate behavior. +- `shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/VbtEnabledRuntimeTest.kt` — live VBT gating without losing history or subordinate configuration. + +### Existing files modified by this plan + +- `shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/VitruvianDatabase.sq` +- `shared/build.gradle.kts` +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/local/MigrationStatements.kt` +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/local/SchemaManifest.kt` +- `shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/local/SchemaParityTest.kt` +- `shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/local/SchemaManifestTest.kt` +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/UserProfileRepository.kt` +- `shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeUserProfileRepository.kt` +- `shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightUserProfileRepositoryTest.kt` +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/migration/MigrationManager.kt` +- `androidApp/src/main/kotlin/com/devil/phoenixproject/VitruvianApp.kt` +- `shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/migration/MigrationManagerTest.kt` +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/App.kt` +- `shared/src/androidMain/kotlin/com/devil/phoenixproject/AndroidAppHost.kt` +- `shared/src/iosMain/kotlin/com/devil/phoenixproject/IosAppHost.kt` +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/UserPreferences.kt` +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/PreferencesManager.kt` +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/SettingsManager.kt` +- `shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/manager/SettingsManagerTest.kt` +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/EquipmentRackRepository.kt` +- `shared/src/commonTest/kotlin/com/devil/phoenixproject/data/repository/EquipmentRackRepositoryTest.kt` +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/integration/HealthBodyWeightSyncManager.kt` +- `shared/src/commonTest/kotlin/com/devil/phoenixproject/data/integration/HealthBodyWeightSyncManagerTest.kt` +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/voice/SafeWordDetectionManager.kt` +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/DefaultWorkoutSessionManager.kt` +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt` +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutCoordinator.kt` +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutUiState.kt` +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutTab.kt` +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ActiveWorkoutScreen.kt` +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutHud.kt` +- `shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngineIntegrationTest.kt` +- `shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutCoordinatorEventTest.kt` +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/BleConnectionManager.kt` +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/MainViewModel.kt` +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BackupModels.kt` +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.kt` +- `shared/src/androidMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.android.kt` +- `shared/src/iosMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.ios.kt` +- `shared/src/commonTest/kotlin/com/devil/phoenixproject/util/BackupSerializationTest.kt` +- `shared/src/androidHostTest/kotlin/com/devil/phoenixproject/util/BackupJsonNavigatorTest.kt` +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/di/DataModule.kt` +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/di/DomainModule.kt` +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/di/KoinInit.kt` +- platform backup bindings and `shared/src/androidHostTest/kotlin/com/devil/phoenixproject/di/KoinModuleVerifyTest.kt` located by `rg -n "DataBackupManager|verifyAll" shared/src` before editing. + +### Stable interfaces produced for later plans + +```kotlin +interface ProfilePreferencesRepository { + fun observe(profileId: String): Flow + suspend fun get(profileId: String): UserProfilePreferences + suspend fun seedMissingProfiles() + suspend fun insertDefaults(profileId: String) + suspend fun updateCore(profileId: String, value: CoreProfilePreferences, now: Long) + suspend fun updateRack(profileId: String, value: RackPreferences, now: Long) + suspend fun updateWorkout(profileId: String, value: WorkoutPreferences, now: Long) + suspend fun updateLed(profileId: String, value: LedPreferences, now: Long) + suspend fun updateVbt(profileId: String, value: VbtPreferences, now: Long) + suspend fun resetInvalidSection(profileId: String, section: ProfilePreferenceSectionName, now: Long) + suspend fun delete(profileId: String) +} + +interface ProfileLocalSafetyStore { + fun read(profileId: String): ProfileLocalSafetyPreferences + fun write(profileId: String, value: ProfileLocalSafetyPreferences) + suspend fun copyLegacyToProfiles(profileIds: List, value: ProfileLocalSafetyPreferences) + fun delete(profileId: String) +} + +sealed interface ActiveProfileContext { + data class Switching(val targetProfileId: String?) : ActiveProfileContext + data class Ready( + val profile: UserProfile, + val preferences: UserProfilePreferences, + val localSafety: ProfileLocalSafetyPreferences, + ) : ActiveProfileContext +} + +class ProfileContextUnavailableException : IllegalStateException("Active profile context is switching") + +class ProfileContextRecoveryException(cause: Throwable) : + IllegalStateException("Could not reconcile the active profile context", cause) + +class StaleProfileContextException(expected: String, actual: String) : + IllegalStateException("Profile changed from $expected to $actual before the update completed") +``` + +### Task 1: Define the typed preference contract and strict codec + +**Files:** +- Create: `shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/ProfilePreferences.kt` +- Create: `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/ProfilePreferencesCodec.kt` +- Create: `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/ProfilePreferencesValidator.kt` +- Create: `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/ProfileWorkoutDefaultsMapper.kt` +- Create: `shared/src/commonTest/kotlin/com/devil/phoenixproject/data/preferences/ProfilePreferencesCodecTest.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/UserPreferences.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/Models.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/ScalingBasis.kt` + +**Interfaces:** +- Consumes: existing `RackItem`, `WeightUnit`, `RepCountTiming`, `ScalingBasis`, and `VulgarTier` domain types. +- Produces: `CoreProfilePreferences`, `RackPreferences`, `WorkoutPreferences`, `LedPreferences`, `VbtPreferences`, `ProfileLocalSafetyPreferences`, `UserProfilePreferences`, `ProfilePreferenceSection`, and `ProfilePreferencesCodec`. + +- [ ] **Step 1: Write failing contract and invalid-section tests** + +```kotlin +class ProfilePreferencesCodecTest { + @Test + fun defaultsMatchVersionOneContract() { + assertEquals(WeightUnit.LB, CoreProfilePreferences().weightUnit) + assertEquals(-1f, CoreProfilePreferences().weightIncrement) + assertEquals(10, WorkoutPreferences().summaryCountdownSeconds) + assertEquals(5, WorkoutPreferences().autoStartCountdownSeconds) + assertTrue(VbtPreferences().enabled) + assertEquals(20, VbtPreferences().velocityLossThresholdPercent) + assertEquals(1, RackPreferences().version) + } + + @Test + fun malformedWorkoutDoesNotInvalidateRack() { + val rack = ProfilePreferencesCodec.decodeRack( + """{"version":1,"items":[]}""", + ) + val workout = ProfilePreferencesCodec.decodeWorkout("{not-json") + + assertEquals(ProfilePreferenceValidity.Valid, rack.validity) + assertIs(workout.validity) + assertEquals(WorkoutPreferences(), workout.value) + assertEquals("{not-json", workout.raw) + } + +} +``` + +- [ ] **Step 2: Run the focused test and confirm the contract is absent** + +Run: + +```powershell +$env:JAVA_HOME='C:\Users\dasbl\AppData\Local\Programs\Android Studio\jbr' +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest --tests "*ProfilePreferencesCodecTest*" --console=plain +``` + +Expected: FAIL during test compilation because `CoreProfilePreferences` and `ProfilePreferencesCodec` do not exist. + +- [ ] **Step 3: Add the complete typed model surface** + +```kotlin +@Serializable +data class CoreProfilePreferences( + val bodyWeightKg: Float = 0f, + val weightUnit: WeightUnit = WeightUnit.LB, + val weightIncrement: Float = -1f, +) + +@Serializable +data class RackPreferences( + val version: Int = 1, + val items: List = emptyList(), +) + +@Serializable +data class JustLiftDefaultsDocument( + val workoutModeId: Int = 0, + val weightPerCableKg: Float = 20f, + val weightChangePerRep: Float = 0f, + val eccentricLoadPercentage: Int = 100, + val echoLevelValue: Int = 1, + val stallDetectionEnabled: Boolean = true, + val repCountTimingName: String = "TOP", + val restSeconds: Int = 60, +) + +@Serializable +data class SingleExerciseDefaultsDocument( + val exerciseId: String, + val setReps: List, + val weightPerCableKg: Float, + val setWeightsPerCableKg: List, + val progressionKg: Float, + val setRestSeconds: List, + val workoutModeId: Int, + val eccentricLoadPercentage: Int, + val echoLevelValue: Int, + val duration: Int, + val isAMRAP: Boolean, + val perSetRestTime: Boolean, + val defaultRackItemIds: List = emptyList(), +) + +@Serializable +data class WorkoutPreferences( + val version: Int = 1, + val stopAtTop: Boolean = false, + val beepsEnabled: Boolean = true, + val stallDetectionEnabled: Boolean = true, + val audioRepCountEnabled: Boolean = false, + val repCountTiming: RepCountTiming = RepCountTiming.TOP, + val summaryCountdownSeconds: Int = 10, + val autoStartCountdownSeconds: Int = 5, + val gamificationEnabled: Boolean = true, + val autoStartRoutine: Boolean = false, + val countdownBeepsEnabled: Boolean = true, + val repSoundEnabled: Boolean = true, + val motionStartEnabled: Boolean = false, + val weightSuggestionsEnabled: Boolean = true, + val defaultRoutineExerciseUsePercentOfPR: Boolean = false, + val defaultRoutineExerciseWeightPercentOfPR: Int = 80, + val voiceStopEnabled: Boolean = false, + val justLiftDefaults: JustLiftDefaultsDocument = JustLiftDefaultsDocument(), + val singleExerciseDefaults: Map = emptyMap(), +) + +@Serializable +data class LedPreferences( + val version: Int = 1, + val colorScheme: Int = 0, + val discoModeUnlocked: Boolean = false, +) + +@Serializable +data class VbtPreferences( + val version: Int = 1, + val enabled: Boolean = true, + val velocityLossThresholdPercent: Int = 20, + val autoEndOnVelocityLoss: Boolean = false, + val defaultScalingBasis: ScalingBasis = ScalingBasis.MAX_WEIGHT_PR, + val verbalEncouragementEnabled: Boolean = false, + val vulgarModeEnabled: Boolean = false, + val vulgarTier: VulgarTier = VulgarTier.STRONG, + val dominatrixModeUnlocked: Boolean = false, + val dominatrixModeActive: Boolean = false, +) + +data class ProfileLocalSafetyPreferences( + val safeWord: String? = null, + val safeWordCalibrated: Boolean = false, + val adultsOnlyConfirmed: Boolean = false, + val adultsOnlyPrompted: Boolean = false, +) + +enum class ProfilePreferenceSectionName { CORE, RACK, WORKOUT, LED, VBT } + +sealed interface ProfilePreferenceValidity { + data object Valid : ProfilePreferenceValidity + data class Invalid(val reason: String) : ProfilePreferenceValidity +} + +data class ProfileSectionMetadata( + val updatedAt: Long, + val localGeneration: Long, + val serverRevision: Long, + val dirty: Boolean, +) + +data class ProfilePreferenceSection( + val value: T, + val raw: String? = null, + val validity: ProfilePreferenceValidity, + val metadata: ProfileSectionMetadata, +) + +data class UserProfilePreferences( + val profileId: String, + val schemaVersion: Int, + val legacyMigrationVersion: Int, + val core: ProfilePreferenceSection, + val rack: ProfilePreferenceSection, + val workout: ProfilePreferenceSection, + val led: ProfilePreferenceSection, + val vbt: ProfilePreferenceSection, +) +``` + +Also add `val vbtEnabled: Boolean = true` to the compatibility `UserPreferences` model. Keep `enableVideoPlayback`, backup, language, BLE compatibility, and `velocityOneRepMaxBackfillDone` there because they remain global. + +Annotate `WeightUnit`, `RepCountTiming`, `VulgarTier`, and `ScalingBasis` with `@Serializable`; their existing enum names are the persisted uppercase values. Do not annotate unrelated domain types. + +Create the shared mappings needed by the startup reader before any consumer routing task runs: + +```kotlin +internal fun SingleExerciseDefaults.toDocument() = SingleExerciseDefaultsDocument( + exerciseId = exerciseId, + setReps = setReps, + weightPerCableKg = weightPerCableKg, + setWeightsPerCableKg = setWeightsPerCableKg, + progressionKg = progressionKg, + setRestSeconds = setRestSeconds, + workoutModeId = workoutModeId, + eccentricLoadPercentage = eccentricLoadPercentage, + echoLevelValue = echoLevelValue, + duration = duration, + isAMRAP = isAMRAP, + perSetRestTime = perSetRestTime, + defaultRackItemIds = defaultRackItemIds, +) + +internal fun SingleExerciseDefaultsDocument.toLegacySingleExerciseDefaults() = SingleExerciseDefaults( + exerciseId = exerciseId, + setReps = setReps, + weightPerCableKg = weightPerCableKg, + setWeightsPerCableKg = setWeightsPerCableKg, + progressionKg = progressionKg, + setRestSeconds = setRestSeconds, + workoutModeId = workoutModeId, + eccentricLoadPercentage = eccentricLoadPercentage, + echoLevelValue = echoLevelValue, + duration = duration, + isAMRAP = isAMRAP, + perSetRestTime = perSetRestTime, + defaultRackItemIds = defaultRackItemIds, +) + +internal fun com.devil.phoenixproject.data.preferences.JustLiftDefaults.toDocument() = + JustLiftDefaultsDocument( + workoutModeId = workoutModeId, + weightPerCableKg = weightPerCableKg, + weightChangePerRep = weightChangePerRep, + eccentricLoadPercentage = eccentricLoadPercentage, + echoLevelValue = echoLevelValue, + stallDetectionEnabled = stallDetectionEnabled, + repCountTimingName = repCountTimingName, + restSeconds = restSeconds, + ) +``` + +- [ ] **Step 4: Implement exact validation and non-destructive decoding** + +```kotlin +object ProfilePreferencesValidator { + fun core(value: CoreProfilePreferences): List = buildList { + if (!value.bodyWeightKg.isFinite() || (value.bodyWeightKg != 0f && value.bodyWeightKg !in 20f..300f)) add("bodyWeightKg") + if (!value.weightIncrement.isFinite() || (value.weightIncrement != -1f && value.weightIncrement <= 0f)) add("weightIncrement") + } + + fun rack(value: RackPreferences): List = buildList { + if (value.version != 1) add("version") + if (value.items.map { it.id }.distinct().size != value.items.size) add("duplicateRackItemId") + value.items.forEach { item -> + if (item.id.isBlank()) add("rackItem.id") + if (item.name.isBlank()) add("rackItem.name") + if (!item.weightKg.isFinite() || item.weightKg < 0f) add("rackItem.weightKg") + } + } + + fun workout(value: WorkoutPreferences): List = buildList { + if (value.version != 1) add("version") + if (value.summaryCountdownSeconds !in setOf(-1, 0, 5, 10, 15, 20, 25, 30)) add("summaryCountdownSeconds") + if (value.autoStartCountdownSeconds !in 2..10) add("autoStartCountdownSeconds") + if (value.defaultRoutineExerciseWeightPercentOfPR !in 50..120) add("defaultRoutineExerciseWeightPercentOfPR") + if (value.justLiftDefaults.restSeconds != 0 && value.justLiftDefaults.restSeconds !in 5..300) add("justLiftDefaults.restSeconds") + if (!value.justLiftDefaults.weightPerCableKg.isFinite() || value.justLiftDefaults.weightPerCableKg < 0f) add("justLiftDefaults.weightPerCableKg") + if (!value.justLiftDefaults.weightChangePerRep.isFinite()) add("justLiftDefaults.weightChangePerRep") + if (value.justLiftDefaults.workoutModeId !in setOf(0, 2, 3, 4, 6, 10)) add("justLiftDefaults.workoutModeId") + if (value.justLiftDefaults.eccentricLoadPercentage !in 0..150) add("justLiftDefaults.eccentricLoadPercentage") + if (value.justLiftDefaults.echoLevelValue !in 0..3) add("justLiftDefaults.echoLevelValue") + if (value.justLiftDefaults.repCountTimingName !in RepCountTiming.entries.map { it.name }) add("justLiftDefaults.repCountTimingName") + value.singleExerciseDefaults.forEach { (key, defaults) -> + if (key != defaults.exerciseId || key.isBlank()) add("singleExerciseDefaults.exerciseId") + if (!defaults.weightPerCableKg.isFinite() || defaults.weightPerCableKg < 0f) add("singleExerciseDefaults.weightPerCableKg") + if (!defaults.progressionKg.isFinite()) add("singleExerciseDefaults.progressionKg") + if (defaults.setWeightsPerCableKg.any { !it.isFinite() || it < 0f }) add("singleExerciseDefaults.setWeightsPerCableKg") + if (defaults.setRestSeconds.any { it != 0 && it !in 5..300 }) add("singleExerciseDefaults.setRestSeconds") + if (defaults.setReps.any { it != null && it < 0 }) add("singleExerciseDefaults.setReps") + if (defaults.workoutModeId !in setOf(0, 2, 3, 4, 6, 10)) add("singleExerciseDefaults.workoutModeId") + if (defaults.eccentricLoadPercentage !in 0..150) add("singleExerciseDefaults.eccentricLoadPercentage") + if (defaults.echoLevelValue !in 0..3) add("singleExerciseDefaults.echoLevelValue") + if (defaults.duration < 0) add("singleExerciseDefaults.duration") + if (defaults.defaultRackItemIds.any { it.isBlank() } || defaults.defaultRackItemIds.distinct().size != defaults.defaultRackItemIds.size) add("singleExerciseDefaults.defaultRackItemIds") + } + } + + fun led(value: LedPreferences): List = buildList { + if (value.version != 1) add("version") + if (value.colorScheme < 0) add("colorScheme") + } + + fun vbt(value: VbtPreferences): List = buildList { + if (value.version != 1) add("version") + if (value.velocityLossThresholdPercent !in 10..50) add("velocityLossThresholdPercent") + } +} + +object ProfilePreferencesCodec { + private val json = Json { + ignoreUnknownKeys = true + explicitNulls = false + encodeDefaults = true + } + + @Serializable + private data class LedPreferencesDocument( + val version: Int = 1, + val discoModeUnlocked: Boolean = false, + ) + + @Serializable + private data class VbtPreferencesDocument( + val version: Int = 1, + val velocityLossThresholdPercent: Int = 20, + val autoEndOnVelocityLoss: Boolean = false, + val defaultScalingBasis: ScalingBasis = ScalingBasis.MAX_WEIGHT_PR, + val verbalEncouragementEnabled: Boolean = false, + val vulgarModeEnabled: Boolean = false, + val vulgarTier: VulgarTier = VulgarTier.STRONG, + val dominatrixModeUnlocked: Boolean = false, + val dominatrixModeActive: Boolean = false, + ) + + fun encodeRack(value: RackPreferences): String = json.encodeToString(value) + fun encodeWorkout(value: WorkoutPreferences): String = json.encodeToString(value) + fun encodeLed(value: LedPreferences): String = json.encodeToString( + LedPreferencesDocument(value.version, value.discoModeUnlocked), + ) + fun encodeVbt(value: VbtPreferences): String = json.encodeToString( + VbtPreferencesDocument( + value.version, + value.velocityLossThresholdPercent, + value.autoEndOnVelocityLoss, + value.defaultScalingBasis, + value.verbalEncouragementEnabled, + value.vulgarModeEnabled, + value.vulgarTier, + value.dominatrixModeUnlocked, + value.dominatrixModeActive, + ), + ) + + fun decodeRack(raw: String) = decode(raw, RackPreferences(), ProfilePreferencesValidator::rack) + fun decodeWorkout(raw: String) = decode(raw, WorkoutPreferences(), ProfilePreferencesValidator::workout) + fun decodeLed(raw: String, colorScheme: Int): DecodedProfileDocument = + decode(raw, LedPreferencesDocument()) { value -> if (value.version == 1) emptyList() else listOf("version") } + .let { decoded -> + decoded.mapValue { value -> LedPreferences(value.version, colorScheme, value.discoModeUnlocked) } + } + fun decodeVbt(raw: String, enabled: Boolean): DecodedProfileDocument = + decode(raw, VbtPreferencesDocument()) { value -> + ProfilePreferencesValidator.vbt( + VbtPreferences( + version = value.version, + enabled = enabled, + velocityLossThresholdPercent = value.velocityLossThresholdPercent, + autoEndOnVelocityLoss = value.autoEndOnVelocityLoss, + defaultScalingBasis = value.defaultScalingBasis, + verbalEncouragementEnabled = value.verbalEncouragementEnabled, + vulgarModeEnabled = value.vulgarModeEnabled, + vulgarTier = value.vulgarTier, + dominatrixModeUnlocked = value.dominatrixModeUnlocked, + dominatrixModeActive = value.dominatrixModeActive, + ), + ) + }.let { decoded -> + decoded.mapValue { value -> + VbtPreferences( + value.version, enabled, value.velocityLossThresholdPercent, + value.autoEndOnVelocityLoss, value.defaultScalingBasis, + value.verbalEncouragementEnabled, value.vulgarModeEnabled, + value.vulgarTier, value.dominatrixModeUnlocked, value.dominatrixModeActive, + ) + } + } + + private inline fun decode( + raw: String, + fallback: T, + validate: (T) -> List, + ): DecodedProfileDocument = runCatching { json.decodeFromString(raw) } + .fold( + onSuccess = { value -> + val errors = validate(value) + DecodedProfileDocument( + value = if (errors.isEmpty()) value else fallback, + raw = raw, + validity = if (errors.isEmpty()) ProfilePreferenceValidity.Valid else ProfilePreferenceValidity.Invalid(errors.joinToString(",")), + ) + }, + onFailure = { error -> + DecodedProfileDocument(fallback, raw, ProfilePreferenceValidity.Invalid(error::class.simpleName ?: "decode")) + }, + ) +} + +data class DecodedProfileDocument( + val value: T, + val raw: String, + val validity: ProfilePreferenceValidity, +) { + fun mapValue(transform: (T) -> R) = DecodedProfileDocument(transform(value), raw, validity) +} +``` + +The private document DTOs deliberately omit the typed row columns, while `encodeDefaults = true` guarantees every local JSON document emits `version`. Assert exact LED keys `version,discoModeUnlocked` and exact VBT keys excluding `enabled`. + +- [ ] **Step 5: Add boundary-table tests and make the codec pass** + +Add parameterized cases for body weight `0`, `20`, `300`, `19.99`, `300.01`, non-finite weights, timer bounds, percentage bounds, duplicate rack IDs, unsupported versions, unknown fields, and missing optional fields. Assert that exact encoded LED keys are `version,discoModeUnlocked` and exact encoded VBT keys exclude `enabled`. + +Run: + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest --tests "*ProfilePreferencesCodecTest*" --tests "*UserPreferencesTest*" --console=plain +``` + +Expected: PASS with no failing tests. + +- [ ] **Step 6: Commit the typed contract** + +```powershell +git add shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/ProfilePreferences.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/UserPreferences.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/Models.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/ScalingBasis.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/ProfilePreferencesCodec.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/ProfilePreferencesValidator.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/ProfileWorkoutDefaultsMapper.kt shared/src/commonTest/kotlin/com/devil/phoenixproject/data/preferences/ProfilePreferencesCodecTest.kt +git commit -m "feat: define profile preference documents" +``` + +### Task 2: Upgrade SQLDelight to schema 43 + +**Files:** +- Create: `shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/migrations/42.sqm` +- Modify: `shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/VitruvianDatabase.sq` +- Modify: `shared/build.gradle.kts` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/local/MigrationStatements.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/local/SchemaManifest.kt` +- Modify: `shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/local/SchemaParityTest.kt` +- Modify: `shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/local/SchemaManifestTest.kt` + +**Interfaces:** +- Consumes: schema 42 and `UserProfile(id)`. +- Produces: schema 43, generated preference/cleanup query APIs, and reconciliation support. + +- [ ] **Step 1: Write failing schema-version and migration-parity tests** + +```kotlin +private const val EXPECTED_SCHEMA_VERSION = 43L + +@Test +fun migration42To43AddsProfilePreferenceTablesAndSeedsProfiles() { + createSchemaAtVersion(42) + driver.execute(null, "INSERT INTO UserProfile(id,name,colorIndex,createdAt,isActive) VALUES('a','A',0,1,1)", 0) + migrateToLatest() + + assertEquals(1L, scalarLong("SELECT count(*) FROM UserProfilePreferences WHERE profile_id='a'")) + assertEquals(1L, scalarLong("SELECT vbt_enabled FROM UserProfilePreferences WHERE profile_id='a'")) + assertEquals(0L, scalarLong("SELECT legacy_migration_version FROM UserProfilePreferences WHERE profile_id='a'")) + assertTableExists("PendingProfileLocalCleanup") + assertTableExists("PendingProfileContextRecovery") +} +``` + +Add a manifest test that drops `UserProfilePreferences`, runs reconciliation, and asserts all three new tables and all columns return. + +- [ ] **Step 2: Run parity tests and verify the expected red state** + +Run: + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest --tests "*SchemaParityTest*" --tests "*SchemaManifestTest*" --console=plain +``` + +Expected: FAIL because schema 43 and migration `42.sqm` are absent. + +- [ ] **Step 3: Add the canonical tables and migration with identical DDL** + +Put this DDL in the canonical `.sq` file, `42.sqm`, and the `MigrationStatements` 42 fallback branch. Keep the canonical schema seed-free. Both executable migration paths—`42.sqm` and the resilient fallback branch—must append the same final seed statement so existing profiles are initialized regardless of which migration mechanism runs. + +```sql +CREATE TABLE UserProfilePreferences ( + profile_id TEXT PRIMARY KEY NOT NULL, + schema_version INTEGER NOT NULL DEFAULT 1, + legacy_migration_version INTEGER NOT NULL DEFAULT 0, + body_weight_kg REAL NOT NULL DEFAULT 0 CHECK(body_weight_kg = 0 OR body_weight_kg BETWEEN 20 AND 300), + weight_unit TEXT NOT NULL DEFAULT 'LB' CHECK(weight_unit IN ('KG', 'LB')), + weight_increment REAL NOT NULL DEFAULT -1 CHECK(weight_increment = -1 OR weight_increment > 0), + core_updated_at INTEGER NOT NULL DEFAULT 0, + core_local_generation INTEGER NOT NULL DEFAULT 0 CHECK(core_local_generation >= 0), + core_server_revision INTEGER NOT NULL DEFAULT 0 CHECK(core_server_revision >= 0), + core_dirty INTEGER NOT NULL DEFAULT 1 CHECK(core_dirty IN (0, 1)), + equipment_rack_json TEXT NOT NULL DEFAULT '{"version":1,"items":[]}', + rack_updated_at INTEGER NOT NULL DEFAULT 0, + rack_local_generation INTEGER NOT NULL DEFAULT 0 CHECK(rack_local_generation >= 0), + rack_server_revision INTEGER NOT NULL DEFAULT 0 CHECK(rack_server_revision >= 0), + rack_dirty INTEGER NOT NULL DEFAULT 1 CHECK(rack_dirty IN (0, 1)), + workout_preferences_json TEXT NOT NULL DEFAULT '{"version":1}', + workout_updated_at INTEGER NOT NULL DEFAULT 0, + workout_local_generation INTEGER NOT NULL DEFAULT 0 CHECK(workout_local_generation >= 0), + workout_server_revision INTEGER NOT NULL DEFAULT 0 CHECK(workout_server_revision >= 0), + workout_dirty INTEGER NOT NULL DEFAULT 1 CHECK(workout_dirty IN (0, 1)), + led_color_scheme_id INTEGER NOT NULL DEFAULT 0 CHECK(led_color_scheme_id >= 0), + led_preferences_json TEXT NOT NULL DEFAULT '{"version":1}', + led_updated_at INTEGER NOT NULL DEFAULT 0, + led_local_generation INTEGER NOT NULL DEFAULT 0 CHECK(led_local_generation >= 0), + led_server_revision INTEGER NOT NULL DEFAULT 0 CHECK(led_server_revision >= 0), + led_dirty INTEGER NOT NULL DEFAULT 1 CHECK(led_dirty IN (0, 1)), + vbt_enabled INTEGER NOT NULL DEFAULT 1 CHECK(vbt_enabled IN (0, 1)), + vbt_preferences_json TEXT NOT NULL DEFAULT '{"version":1}', + vbt_updated_at INTEGER NOT NULL DEFAULT 0, + vbt_local_generation INTEGER NOT NULL DEFAULT 0 CHECK(vbt_local_generation >= 0), + vbt_server_revision INTEGER NOT NULL DEFAULT 0 CHECK(vbt_server_revision >= 0), + vbt_dirty INTEGER NOT NULL DEFAULT 1 CHECK(vbt_dirty IN (0, 1)), + FOREIGN KEY (profile_id) REFERENCES UserProfile(id) ON DELETE CASCADE +); + +CREATE TABLE PendingProfileLocalCleanup ( + profile_id TEXT PRIMARY KEY NOT NULL, + enqueued_at INTEGER NOT NULL +); + +CREATE TABLE PendingProfileContextRecovery ( + recovery_key TEXT PRIMARY KEY NOT NULL + DEFAULT 'active_profile_transition' + CHECK(recovery_key = 'active_profile_transition'), + prior_profile_id TEXT NOT NULL, + created_profile_id TEXT, + enqueued_at INTEGER NOT NULL +); +``` + +Append to both `42.sqm` and the `MigrationStatements` 42 fallback branch, but not the canonical `.sq` schema: + +```sql +INSERT OR IGNORE INTO UserProfilePreferences(profile_id) +SELECT id FROM UserProfile; +``` + +- [ ] **Step 4: Add exact SQLDelight write and cleanup queries** + +```sql +selectProfilePreferences: +SELECT * FROM UserProfilePreferences WHERE profile_id = ?; + +selectAllProfilePreferences: +SELECT * FROM UserProfilePreferences ORDER BY profile_id; + +insertDefaultProfilePreferences: +INSERT OR IGNORE INTO UserProfilePreferences(profile_id, legacy_migration_version) +VALUES (?, ?); + +seedMissingProfilePreferences: +INSERT OR IGNORE INTO UserProfilePreferences(profile_id) +SELECT id FROM UserProfile; + +updateCoreProfilePreferences: +UPDATE UserProfilePreferences +SET body_weight_kg = ?, weight_unit = ?, weight_increment = ?, core_updated_at = ?, + core_local_generation = core_local_generation + 1, core_dirty = 1 +WHERE profile_id = ?; + +updateRackProfilePreferences: +UPDATE UserProfilePreferences +SET equipment_rack_json = ?, rack_updated_at = ?, rack_local_generation = rack_local_generation + 1, rack_dirty = 1 +WHERE profile_id = ?; + +updateWorkoutProfilePreferences: +UPDATE UserProfilePreferences +SET workout_preferences_json = ?, workout_updated_at = ?, workout_local_generation = workout_local_generation + 1, workout_dirty = 1 +WHERE profile_id = ?; + +updateLedProfilePreferences: +UPDATE UserProfilePreferences +SET led_color_scheme_id = ?, led_preferences_json = ?, led_updated_at = ?, + led_local_generation = led_local_generation + 1, led_dirty = 1 +WHERE profile_id = ?; + +updateVbtProfilePreferences: +UPDATE UserProfilePreferences +SET vbt_enabled = ?, vbt_preferences_json = ?, vbt_updated_at = ?, + vbt_local_generation = vbt_local_generation + 1, vbt_dirty = 1 +WHERE profile_id = ?; + +deleteProfilePreferences: +DELETE FROM UserProfilePreferences WHERE profile_id = ?; + +enqueueProfileLocalCleanup: +INSERT OR REPLACE INTO PendingProfileLocalCleanup(profile_id, enqueued_at) VALUES (?, ?); + +selectPendingProfileLocalCleanup: +SELECT * FROM PendingProfileLocalCleanup ORDER BY enqueued_at, profile_id; + +dequeueProfileLocalCleanup: +DELETE FROM PendingProfileLocalCleanup WHERE profile_id = ?; + +enqueueProfileContextRecovery: +INSERT OR REPLACE INTO PendingProfileContextRecovery( + recovery_key, prior_profile_id, created_profile_id, enqueued_at +) VALUES ('active_profile_transition', ?, ?, ?); + +selectPendingProfileContextRecovery: +SELECT * FROM PendingProfileContextRecovery +WHERE recovery_key = 'active_profile_transition'; + +clearPendingProfileContextRecovery: +DELETE FROM PendingProfileContextRecovery +WHERE recovery_key = 'active_profile_transition'; +``` + +Add this guarded legacy-copy statement: + +```sql +applyLegacyProfilePreferences: +UPDATE UserProfilePreferences +SET schema_version = 1, + legacy_migration_version = 1, + body_weight_kg = :body_weight_kg, + weight_unit = :weight_unit, + weight_increment = :weight_increment, + core_updated_at = :migrated_at, + core_local_generation = 1, + core_server_revision = 0, + core_dirty = 1, + equipment_rack_json = :equipment_rack_json, + rack_updated_at = :migrated_at, + rack_local_generation = 1, + rack_server_revision = 0, + rack_dirty = 1, + workout_preferences_json = :workout_preferences_json, + workout_updated_at = :migrated_at, + workout_local_generation = 1, + workout_server_revision = 0, + workout_dirty = 1, + led_color_scheme_id = :led_color_scheme_id, + led_preferences_json = :led_preferences_json, + led_updated_at = :migrated_at, + led_local_generation = 1, + led_server_revision = 0, + led_dirty = 1, + vbt_enabled = :vbt_enabled, + vbt_preferences_json = :vbt_preferences_json, + vbt_updated_at = :migrated_at, + vbt_local_generation = 1, + vbt_server_revision = 0, + vbt_dirty = 1 +WHERE profile_id = :profile_id AND legacy_migration_version = 0; +``` + +- [ ] **Step 5: Reconcile every schema-version path** + +Set SQLDelight `version = 43` in `shared/build.gradle.kts`; set the parity constant to `43`; add a `42 ->` branch in `MigrationStatements.kt` containing the same DDL and seed; and add all three table operations with every column to `SchemaManifest.kt`. Preserve the existing operation ordering: `UserProfile`, `UserProfilePreferences`, `PendingProfileContextRecovery`, `PendingProfileLocalCleanup`, then dependent tables. + +- [ ] **Step 6: Generate interfaces and run all schema checks** + +Run: + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:generateCommonMainVitruvianDatabaseInterface :shared:verifyCommonMainVitruvianDatabaseMigration :shared:validateSchemaManifest --console=plain +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest --tests "*SchemaParityTest*" --tests "*SchemaManifestTest*" --console=plain +``` + +Expected: both commands exit 0; migration parity reports schema 43 and no missing manifest operations. + +- [ ] **Step 7: Commit schema 43** + +```powershell +git add shared/build.gradle.kts shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/VitruvianDatabase.sq shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/migrations/42.sqm shared/src/commonMain/kotlin/com/devil/phoenixproject/data/local/MigrationStatements.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/data/local/SchemaManifest.kt shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/local/SchemaParityTest.kt shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/local/SchemaManifestTest.kt +git commit -m "feat: add profile preference schema" +``` + +### Task 3: Implement the preference store, local safety store, and active-profile facade + +**Files:** +- Create: `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/ProfilePreferencesRepository.kt` +- Create: `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/ProfileLocalSafetyStore.kt` +- Create: `shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightProfilePreferencesRepositoryTest.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/UserProfileRepository.kt` +- Modify: `shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeUserProfileRepository.kt` +- Modify: `shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightUserProfileRepositoryTest.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/di/DataModule.kt` + +**Interfaces:** +- Consumes: generated schema-43 queries and Task 1's typed codec. +- Produces: `ProfilePreferencesRepository`, `ProfileLocalSafetyStore`, `UserProfileRepository.activeProfileContext`, typed update methods, atomic `createAndActivateProfile`, the idempotent pending-local-cleanup processor needed by Task 4, and compile-safe focused-store/repository DI bindings. + +- [ ] **Step 1: Write failing section-isolation and switch-order tests** + +```kotlin +@Test +fun editingCoreOnlyAdvancesCoreMetadata() = runTest { + repository.insertDefaults("a") + val before = repository.get("a") + + repository.updateCore("a", before.core.value.copy(bodyWeightKg = 80f), now = 200) + val after = repository.get("a") + + assertEquals(before.core.metadata.localGeneration + 1, after.core.metadata.localGeneration) + assertEquals(200, after.core.metadata.updatedAt) + assertTrue(after.core.metadata.dirty) + assertEquals(before.core.metadata.serverRevision, after.core.metadata.serverRevision) + assertEquals(before.rack, after.rack) + assertEquals(before.workout, after.workout) + assertEquals(before.led, after.led) + assertEquals(before.vbt, after.vbt) +} + +@Test +fun switchingNeverEmitsMixedProfileContext() = runTest { + facade.createAndActivateProfile("A", 1) + val profileA = facade.activeProfile.value!! + facade.createAndActivateProfile("B", 2) + val profileB = facade.activeProfile.value!! + val observed = mutableListOf() + val job = launch(UnconfinedTestDispatcher(testScheduler)) { + facade.activeProfileContext.take(2).toList(observed) + } + + facade.setActiveProfile(profileA.id) + + assertIs(observed[0]) + val ready = assertIs(observed[1]) + assertEquals(profileA.id, ready.profile.id) + assertEquals(profileA.id, ready.preferences.profileId) + assertNotEquals(profileB.id, ready.profile.id) + job.cancel() +} + +@Test +fun failedCreateAfterActivationRestoresPreviousDatabaseAndReadyContext() = runTest { + val previous = assertIs(facade.activeProfileContext.value) + val profileIdsBefore = facade.allProfiles.value.map { it.id }.toSet() + val preferenceIdsBefore = queries.selectAllProfilePreferences().executeAsList().map { it.profile_id }.toSet() + preferenceStore.failNextGet = true + + assertFails { facade.createAndActivateProfile("Cannot publish", 2) } + + assertEquals(previous.profile.id, queries.getActiveProfile().executeAsOne().id) + assertEquals(previous, facade.activeProfileContext.value) + assertEquals(profileIdsBefore, facade.allProfiles.value.map { it.id }.toSet()) + assertEquals( + preferenceIdsBefore, + queries.selectAllProfilePreferences().executeAsList().map { it.profile_id }.toSet(), + ) +} + +@Test +fun failedSwitchAfterDatabaseActivationRestoresPreviousDatabaseAndReadyContext() = runTest { + val target = facade.createProfile("Target", 2) + val previous = assertIs(facade.activeProfileContext.value) + preferenceStore.failNextGetFor = target.id + + assertFails { facade.setActiveProfile(target.id) } + + assertEquals(previous.profile.id, queries.getActiveProfile().executeAsOne().id) + assertEquals(previous, facade.activeProfileContext.value) +} + +@Test +fun rollbackFailureStaysJournaledUntilReconciliationRestoresDatabaseAndReady() = runTest { + val target = facade.createProfile("Target", 2) + val previous = assertIs(facade.activeProfileContext.value) + preferenceStore.failNextGetFor = target.id + transitionFaults.failNextRecoveryTransaction = true + + assertFailsWith { facade.setActiveProfile(target.id) } + assertNotNull(queries.selectPendingProfileContextRecovery().executeAsOneOrNull()) + + facade.reconcileActiveProfileContext() + + assertNull(queries.selectPendingProfileContextRecovery().executeAsOneOrNull()) + assertEquals(previous.profile.id, queries.getActiveProfile().executeAsOne().id) + assertEquals(previous.profile.id, assertIs(facade.activeProfileContext.value).profile.id) +} + +@Test +fun failedCreateCompensationRetriesFromJournalWithoutLeavingRows() = runTest { + val profileIdsBefore = facade.allProfiles.value.map { it.id }.toSet() + preferenceStore.failNextGet = true + transitionFaults.failNextCreatedProfileDelete = true + + assertFailsWith { + facade.createAndActivateProfile("Failed create", 2) + } + val pending = queries.selectPendingProfileContextRecovery().executeAsOne() + val failedId = assertNotNull(pending.created_profile_id) + + facade.reconcileActiveProfileContext() + + assertNull(queries.selectPendingProfileContextRecovery().executeAsOneOrNull()) + assertNull(queries.getProfileById(failedId).executeAsOneOrNull()) + assertNull(queries.selectProfilePreferences(failedId).executeAsOneOrNull()) + assertEquals(profileIdsBefore, facade.allProfiles.value.map { it.id }.toSet()) +} + +@Test +fun pendingLocalCleanupDequeuesOnlyAfterEverySafetyKeyIsRemoved() = runTest { + queries.enqueueProfileLocalCleanup("source", 100) + safetyStore.failDeletes = true + + facade.retryPendingLocalCleanup() + assertEquals(listOf("source"), queries.selectPendingProfileLocalCleanup().executeAsList().map { it.profile_id }) + + safetyStore.failDeletes = false + facade.retryPendingLocalCleanup() + assertTrue(queries.selectPendingProfileLocalCleanup().executeAsList().isEmpty()) + assertNull(settings.getStringOrNull("profile_source_safe_word")) +} +``` + +Add tests proving malformed stored workout JSON returns typed defaults with `Invalid`, remains byte-for-byte unchanged after a rack edit, and becomes valid only through `resetInvalidSection(WORKOUT)` or a valid workout update. Implement `transitionFaults` with a test-only failing driver/query wrapper; do not put failure switches in production code. + +- [ ] **Step 2: Run repository tests and verify they fail before implementation** + +Run: + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest --tests "*SqlDelightProfilePreferencesRepositoryTest*" --tests "*SqlDelightUserProfileRepositoryTest*" --console=plain +``` + +Expected: FAIL during compilation because the focused repository and active context are absent. + +- [ ] **Step 3: Implement the focused SQLDelight store** + +```kotlin +interface ProfilePreferencesRepository { + fun observe(profileId: String): Flow + suspend fun get(profileId: String): UserProfilePreferences + suspend fun seedMissingProfiles() + suspend fun insertDefaults(profileId: String) + suspend fun updateCore(profileId: String, value: CoreProfilePreferences, now: Long) + suspend fun updateRack(profileId: String, value: RackPreferences, now: Long) + suspend fun updateWorkout(profileId: String, value: WorkoutPreferences, now: Long) + suspend fun updateLed(profileId: String, value: LedPreferences, now: Long) + suspend fun updateVbt(profileId: String, value: VbtPreferences, now: Long) + suspend fun resetInvalidSection(profileId: String, section: ProfilePreferenceSectionName, now: Long) + suspend fun delete(profileId: String) +} + +class SqlDelightProfilePreferencesRepository( + private val database: VitruvianDatabase, +) : ProfilePreferencesRepository { + private val queries = database.vitruvianDatabaseQueries + + override fun observe(profileId: String): Flow = + queries.selectProfilePreferences(profileId) + .asFlow() + .mapToOne(Dispatchers.IO) + .map(::mapRow) + + override suspend fun get(profileId: String): UserProfilePreferences = + mapRow(queries.selectProfilePreferences(profileId).executeAsOne()) + + override suspend fun seedMissingProfiles() = queries.seedMissingProfilePreferences() + + override suspend fun insertDefaults(profileId: String) { + queries.insertDefaultProfilePreferences(profileId, 1) + } + + override suspend fun updateCore(profileId: String, value: CoreProfilePreferences, now: Long) { + require(ProfilePreferencesValidator.core(value).isEmpty()) + queries.updateCoreProfilePreferences( + value.bodyWeightKg.toDouble(), value.weightUnit.name, value.weightIncrement.toDouble(), now, profileId, + ) + } + + override suspend fun updateRack(profileId: String, value: RackPreferences, now: Long) { + require(ProfilePreferencesValidator.rack(value).isEmpty()) + queries.updateRackProfilePreferences(ProfilePreferencesCodec.encodeRack(value), now, profileId) + } + + override suspend fun updateWorkout(profileId: String, value: WorkoutPreferences, now: Long) { + require(ProfilePreferencesValidator.workout(value).isEmpty()) + queries.updateWorkoutProfilePreferences(ProfilePreferencesCodec.encodeWorkout(value), now, profileId) + } + + override suspend fun updateLed(profileId: String, value: LedPreferences, now: Long) { + require(ProfilePreferencesValidator.led(value).isEmpty()) + queries.updateLedProfilePreferences(value.colorScheme.toLong(), ProfilePreferencesCodec.encodeLed(value), now, profileId) + } + + override suspend fun updateVbt(profileId: String, value: VbtPreferences, now: Long) { + require(ProfilePreferencesValidator.vbt(value).isEmpty()) + queries.updateVbtProfilePreferences(if (value.enabled) 1 else 0, ProfilePreferencesCodec.encodeVbt(value), now, profileId) + } + + override suspend fun delete(profileId: String) = queries.deleteProfilePreferences(profileId) +} +``` + +Construct each section without letting one decode affect another: + +```kotlin +private fun mapRow(row: ProfilePreferencesRow): UserProfilePreferences { + fun metadata(updatedAt: Long, generation: Long, revision: Long, dirty: Long) = + ProfileSectionMetadata(updatedAt, generation, revision, dirty == 1L) + + val storedCore = CoreProfilePreferences( + bodyWeightKg = row.body_weight_kg.toFloat(), + weightUnit = WeightUnit.valueOf(row.weight_unit), + weightIncrement = row.weight_increment.toFloat(), + ) + val coreErrors = ProfilePreferencesValidator.core(storedCore) + val rack = ProfilePreferencesCodec.decodeRack(row.equipment_rack_json) + val workout = ProfilePreferencesCodec.decodeWorkout(row.workout_preferences_json) + val led = ProfilePreferencesCodec.decodeLed(row.led_preferences_json, row.led_color_scheme_id.toInt()) + val vbt = ProfilePreferencesCodec.decodeVbt(row.vbt_preferences_json, row.vbt_enabled == 1L) + + return UserProfilePreferences( + profileId = row.profile_id, + schemaVersion = row.schema_version.toInt(), + legacyMigrationVersion = row.legacy_migration_version.toInt(), + core = ProfilePreferenceSection( + value = if (coreErrors.isEmpty()) storedCore else CoreProfilePreferences(), + validity = if (coreErrors.isEmpty()) ProfilePreferenceValidity.Valid else ProfilePreferenceValidity.Invalid(coreErrors.joinToString(",")), + metadata = metadata(row.core_updated_at, row.core_local_generation, row.core_server_revision, row.core_dirty), + ), + rack = ProfilePreferenceSection(rack.value, rack.raw, rack.validity, metadata(row.rack_updated_at, row.rack_local_generation, row.rack_server_revision, row.rack_dirty)), + workout = ProfilePreferenceSection(workout.value, workout.raw, workout.validity, metadata(row.workout_updated_at, row.workout_local_generation, row.workout_server_revision, row.workout_dirty)), + led = ProfilePreferenceSection(led.value, led.raw, led.validity, metadata(row.led_updated_at, row.led_local_generation, row.led_server_revision, row.led_dirty)), + vbt = ProfilePreferenceSection(vbt.value, vbt.raw, vbt.validity, metadata(row.vbt_updated_at, row.vbt_local_generation, row.vbt_server_revision, row.vbt_dirty)), + ) +} +``` + +Alias the generated row import as `ProfilePreferencesRow` to avoid colliding with the domain aggregate. `resetInvalidSection` writes the documented default through that section's normal update method, which increments its generation and preserves its server revision. + +- [ ] **Step 4: Add profile-prefixed safety persistence** + +```kotlin +class SettingsProfileLocalSafetyStore( + private val settings: Settings, +) : ProfileLocalSafetyStore { + private fun key(profileId: String, suffix: String) = "profile_${profileId}_$suffix" + + override fun read(profileId: String) = ProfileLocalSafetyPreferences( + safeWord = settings.getStringOrNull(key(profileId, "safe_word")), + safeWordCalibrated = settings.getBoolean(key(profileId, "safe_word_calibrated"), false), + adultsOnlyConfirmed = settings.getBoolean(key(profileId, "adults_only_confirmed"), false), + adultsOnlyPrompted = settings.getBoolean(key(profileId, "adults_only_prompted"), false), + ) + + override fun write(profileId: String, value: ProfileLocalSafetyPreferences) { + val previous = read(profileId) + try { + writeKeys(profileId, value) + } catch (error: Exception) { + runCatching { writeKeys(profileId, previous) } + throw error + } + } + + private fun writeKeys(profileId: String, value: ProfileLocalSafetyPreferences) { + value.safeWord?.let { settings.putString(key(profileId, "safe_word"), it) } + ?: settings.remove(key(profileId, "safe_word")) + settings.putBoolean(key(profileId, "safe_word_calibrated"), value.safeWordCalibrated) + settings.putBoolean(key(profileId, "adults_only_confirmed"), value.adultsOnlyConfirmed) + settings.putBoolean(key(profileId, "adults_only_prompted"), value.adultsOnlyPrompted) + } + + override suspend fun copyLegacyToProfiles( + profileIds: List, + value: ProfileLocalSafetyPreferences, + ) = profileIds.forEach { write(it, value) } + + override fun delete(profileId: String) { + listOf("safe_word", "safe_word_calibrated", "adults_only_confirmed", "adults_only_prompted") + .forEach { settings.remove(key(profileId, it)) } + } +} +``` + +`copyLegacyToProfiles` is suspending because it belongs to the awaited migration pipeline and its fake uses a `suspend () -> Unit` timing hook. The production implementation writes the supplied immutable snapshot to every profile ID without changing dispatchers. The legacy reader is the only component that reads the four old keys. Do not log the phrase or any copied value. + +- [ ] **Step 5: Expand the public profile facade and make creation atomic** + +```kotlin +interface UserProfileRepository { + val activeProfile: StateFlow + val allProfiles: StateFlow> + val activeProfileContext: StateFlow + + fun observePreferences(profileId: String): Flow + suspend fun createAndActivateProfile(name: String, colorIndex: Int): UserProfile + suspend fun updateCore(profileId: String, value: CoreProfilePreferences) + suspend fun updateRack(profileId: String, value: RackPreferences) + suspend fun updateWorkout(profileId: String, value: WorkoutPreferences) + suspend fun updateLed(profileId: String, value: LedPreferences) + suspend fun updateVbt(profileId: String, value: VbtPreferences) + suspend fun updateLocalSafety(profileId: String, value: ProfileLocalSafetyPreferences) + suspend fun retryPendingLocalCleanup(profileId: String? = null) + suspend fun recoverPendingProfileTransitionForStartup() + suspend fun reconcileActiveProfileContext() +} +``` + +Keep existing identity/subscription methods. Construct `SqlDelightUserProfileRepository` with `ProfilePreferencesRepository`, `ProfileLocalSafetyStore`, and `GamificationRepository`. Initialize `activeProfileContext` as `Switching(activeProfile.value?.id)` and do not publish `Ready` from the constructor. The required migration first calls `recoverPendingProfileTransitionForStartup()`, which drains the journal and refreshes identity flows while deliberately retaining `Switching`; it calls `reconcileActiveProfileContext()` only after legacy values and local safety are copied. Keep `createProfile` as a backward-compatible non-activating operation, but make it insert the identity row and `insertDefaultProfilePreferences(id, 1)` in one SQL transaction. All new UI uses this activating operation: + +```kotlin +private suspend fun withProfileContextTransition( + targetProfileId: String?, + operation: suspend (previous: ActiveProfileContext.Ready) -> T, +): T { + val previous = _activeProfileContext.value as? ActiveProfileContext.Ready + ?: throw ProfileContextUnavailableException() + _activeProfileContext.value = ActiveProfileContext.Switching(targetProfileId) + return try { + operation(previous) + } catch (failure: Throwable) { + _activeProfileContext.value = ActiveProfileContext.Switching(previous.profile.id) + runCatching { + reconcileActiveProfileContextLocked(publishReady = true) + }.getOrElse { recoveryFailure -> + failure.addSuppressed(recoveryFailure) + throw ProfileContextRecoveryException(failure) + } + throw failure + } +} + +override suspend fun createAndActivateProfile(name: String, colorIndex: Int): UserProfile = + profileContextMutex.withLock { + val trimmedName = name.trim() + require(trimmedName.isNotEmpty()) { "Profile name must not be blank" } + val id = generateUUID() + val createdAt = currentTimeMillis() + withProfileContextTransition(id) { previous -> + database.transaction { + queries.insertProfile(id, trimmedName, colorIndex.toLong(), createdAt, 0) + queries.insertDefaultProfilePreferences(id, 1) + queries.enqueueProfileContextRecovery(previous.profile.id, id, createdAt) + queries.setActiveProfile(id) + } + refreshProfilesSync() + publishReadyContext(id) + val created = activeProfile.value ?: error("Activated profile missing: $id") + queries.clearPendingProfileContextRecovery() + created + } + } + +override suspend fun setActiveProfile(id: String) { + profileContextMutex.withLock { + require(allProfiles.value.any { it.id == id }) { "Unknown profile: $id" } + withProfileContextTransition(id) { previous -> + database.transaction { + queries.enqueueProfileContextRecovery( + prior_profile_id = previous.profile.id, + created_profile_id = null, + enqueued_at = currentTimeMillis(), + ) + queries.setActiveProfile(id) + } + refreshProfilesSync() + publishReadyContext(id) + queries.clearPendingProfileContextRecovery() + } + } +} + +override suspend fun updateProfile(id: String, name: String, colorIndex: Int) { + profileContextMutex.withLock { + val trimmed = name.trim() + require(trimmed.isNotEmpty()) { "Profile name must not be blank" } + queries.updateProfile(trimmed, colorIndex.toLong(), id) + refreshProfilesSync() + if (activeProfile.value?.id == id) publishReadyContext(id) + } +} +``` + +Wrap `createAndActivateProfile`, `setActiveProfile`, reconciliation, deletion, and every public typed update in the same `profileContextMutex`. The transition journal is written in the same transaction as activation and cleared only after a complete Ready context has been published. Define `ProfileContextRecoveryException` for the exceptional case where journal recovery itself fails; route that exception to the app's blocking retry surface rather than returning to the switcher. Each update requires its `profileId` to equal the current `Ready.profile.id`; a stale screen write throws `StaleProfileContextException` rather than touching the newly active profile. It writes through `ProfilePreferencesRepository`, then republishes a complete `Ready` value for that same ID. If context is `Switching`, throw `ProfileContextUnavailableException` so workout-start callers remain blocked. + +```kotlin +private suspend fun mutateActiveProfile( + expectedProfileId: String, + write: suspend () -> Unit, +) = profileContextMutex.withLock { + val context = _activeProfileContext.value as? ActiveProfileContext.Ready + ?: throw ProfileContextUnavailableException() + if (context.profile.id != expectedProfileId) { + throw StaleProfileContextException(expectedProfileId, context.profile.id) + } + write() + publishReadyContext(expectedProfileId) +} + +override suspend fun updateCore(profileId: String, value: CoreProfilePreferences) = + mutateActiveProfile(profileId) { profilePreferencesRepository.updateCore(profileId, value, currentTimeMillis()) } + +override suspend fun updateRack(profileId: String, value: RackPreferences) = + mutateActiveProfile(profileId) { profilePreferencesRepository.updateRack(profileId, value, currentTimeMillis()) } + +override suspend fun updateWorkout(profileId: String, value: WorkoutPreferences) = + mutateActiveProfile(profileId) { profilePreferencesRepository.updateWorkout(profileId, value, currentTimeMillis()) } + +override suspend fun updateLed(profileId: String, value: LedPreferences) = + mutateActiveProfile(profileId) { profilePreferencesRepository.updateLed(profileId, value, currentTimeMillis()) } + +override suspend fun updateVbt(profileId: String, value: VbtPreferences) = + mutateActiveProfile(profileId) { profilePreferencesRepository.updateVbt(profileId, value, currentTimeMillis()) } + +override suspend fun updateLocalSafety(profileId: String, value: ProfileLocalSafetyPreferences) = + mutateActiveProfile(profileId) { profileLocalSafetyStore.write(profileId, value) } + +private suspend fun reconcileActiveProfileContextLocked(publishReady: Boolean) { + queries.selectPendingProfileContextRecovery().executeAsOneOrNull()?.let { pending -> + database.transaction { + val priorId = queries.getProfileById(pending.prior_profile_id) + .executeAsOneOrNull() + ?.id + ?: "default" + queries.setActiveProfile(priorId) + pending.created_profile_id?.let { failedCreatedId -> + queries.deleteProfilePreferences(failedCreatedId) + queries.deleteProfile(failedCreatedId) + } + queries.clearPendingProfileContextRecovery() + } + } + refreshProfilesSync() + val actualActiveId = queries.getActiveProfile().executeAsOneOrNull()?.id + ?: error("No active profile after context reconciliation") + if (publishReady) { + publishReadyContext(actualActiveId) + } else { + _activeProfileContext.value = ActiveProfileContext.Switching(actualActiveId) + } +} + +override suspend fun recoverPendingProfileTransitionForStartup() = profileContextMutex.withLock { + _activeProfileContext.value = ActiveProfileContext.Switching(null) + try { + reconcileActiveProfileContextLocked(publishReady = false) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + throw ProfileContextRecoveryException(error) + } +} + +override suspend fun reconcileActiveProfileContext() = profileContextMutex.withLock { + _activeProfileContext.value = ActiveProfileContext.Switching( + queries.selectPendingProfileContextRecovery().executeAsOneOrNull()?.prior_profile_id, + ) + try { + reconcileActiveProfileContextLocked(publishReady = true) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + throw ProfileContextRecoveryException(error) + } +} + +private val profileCleanupMutex = Mutex() + +override suspend fun retryPendingLocalCleanup(profileId: String?) = profileCleanupMutex.withLock { + queries.selectPendingProfileLocalCleanup().executeAsList() + .asSequence() + .filter { profileId == null || it.profile_id == profileId } + .forEach { pending -> + try { + profileLocalSafetyStore.delete(pending.profile_id) + queries.dequeueProfileLocalCleanup(pending.profile_id) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + Logger.w(error) { "Profile local cleanup remains queued profile=${pending.profile_id}" } + } + } +} +``` + +- [ ] **Step 6: Update fakes and pass isolation tests** + +Make `FakeUserProfileRepository` hold a `MutableStateFlow` and implement the same whole-section updates, cleanup, startup recovery, and reconciliation hooks. Add tests for A/B values across every section, atomic create/default/activate, invalid update rejection, local safety isolation, `Switching -> Ready` order, failed create after database activation, and failed switch after database activation. Both ordinary failure tests must assert that SQLite's active ID and `activeProfileContext.Ready.profile.id` return to the same prior profile; the failed-create case must also prove that neither the identity nor its default-preference row remains. Then force rollback and failed-create deletion failures separately, prove the journal remains, retry `reconcileActiveProfileContext()`, and assert the journal drains only after SQLite, identity rows, preference rows, and Ready all agree. Separately assert that `recoverPendingProfileTransitionForStartup()` drains the journal but leaves the context in `Switching`, and that pending local cleanup remains queued on Settings failure and drains on retry. + +- [ ] **Step 7: Update the focused DI bindings before compiling later tasks** + +Replace the existing one-argument `SqlDelightUserProfileRepository(get())` binding in `DataModule.kt` now, in the same task that changes its constructor: + +```kotlin +single { SqlDelightProfilePreferencesRepository(get()) } +single { SettingsProfileLocalSafetyStore(get()) } +single { + SqlDelightUserProfileRepository( + database = get(), + profilePreferencesRepository = get(), + profileLocalSafetyStore = get(), + gamificationRepository = get(), + ) +} +``` + +Do not register `LegacyProfilePreferencesReader`, the refactored `SettingsManager`, or backup dependencies yet; their implementations land in later tasks and Task 9 performs final graph verification. + +- [ ] **Step 8: Run focused tests and compile the affected graph** + +Run: + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest --tests "*SqlDelightProfilePreferencesRepositoryTest*" --tests "*SqlDelightUserProfileRepositoryTest*" --console=plain +.\gradlew.bat '-Pskip.supabase.check=true' :shared:compileAndroidMain --console=plain +``` + +Expected: PASS, including the malformed-raw retention test. + +- [ ] **Step 9: Commit the repositories and compile-safe bindings** + +```powershell +git add shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/ProfilePreferencesRepository.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/ProfileLocalSafetyStore.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/UserProfileRepository.kt shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeUserProfileRepository.kt shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightProfilePreferencesRepositoryTest.kt shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightUserProfileRepositoryTest.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/di/DataModule.kt +git commit -m "feat: add active profile preference facade" +``` + +### Task 4: Copy legacy values behind an awaited startup gate + +**Files:** +- Create: `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/LegacyProfilePreferencesReader.kt` +- Create: `shared/src/commonTest/kotlin/com/devil/phoenixproject/data/preferences/LegacyProfilePreferencesReaderTest.kt` +- Create: `shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/migration/ProfilePreferencesMigrationTest.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/PreferencesManager.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/EquipmentRackRepository.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/migration/MigrationManager.kt` +- Modify: `shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/migration/MigrationManagerTest.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/di/DataModule.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/di/DomainModule.kt` +- Modify: `androidApp/src/main/kotlin/com/devil/phoenixproject/VitruvianApp.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/di/KoinInit.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/App.kt` +- Modify: `shared/src/androidMain/kotlin/com/devil/phoenixproject/AndroidAppHost.kt` +- Modify: `shared/src/iosMain/kotlin/com/devil/phoenixproject/IosAppHost.kt` + +**Interfaces:** +- Consumes: existing legacy `PreferencesManager`/Settings keys and the Task 3 stores. +- Produces: `RequiredMigrationState`, `MigrationManager.requiredMigrationState`, `runRequiredMigrations`, `retryRequiredMigrations`, and `awaitRequiredMigrations`. + +- [ ] **Step 1: Write failing normalization, retry, and boot-gate tests** + +```kotlin +@Test +fun corruptLegacyFieldsNormalizeWithoutFailingMigration() = runTest { + settings.putString("weight_unit", "STONE") + settings.putFloat("body_weight_kg", Float.NaN) + settings.putString("equipment_rack_items_v1", "[{\"id\":\"x\"},{\"id\":\"x\"}]") + settings.putString("exercise_defaults_broken", "{broken") + + val snapshot = reader.readNormalized() + + assertEquals(WeightUnit.LB, snapshot.core.weightUnit) + assertEquals(0f, snapshot.core.bodyWeightKg) + assertEquals(emptyList(), snapshot.rack.items) + assertEquals(emptyMap(), snapshot.workout.singleExerciseDefaults) + assertTrue(snapshot.vbt.enabled) +} + +@Test +fun partialFailureRetriesWithoutOverwritingCompletedRows() = runTest { + createProfiles("a", "b") + safetyStore.failProfileId = "b" + migration.runRequiredMigrations() + assertIs(migration.requiredMigrationState.value) + preferenceRepository.updateCore("a", CoreProfilePreferences(bodyWeightKg = 90f), 300) + + safetyStore.failProfileId = null + migration.retryRequiredMigrations() + + assertEquals(90f, preferenceRepository.get("a").core.value.bodyWeightKg) + assertEquals(1, preferenceRepository.get("a").legacyMigrationVersion) + assertEquals(1, preferenceRepository.get("b").legacyMigrationVersion) + assertEquals(RequiredMigrationState.Ready, migration.requiredMigrationState.value) +} + +@Test +fun startupReconciliationDrainsInterruptedCreateBeforeLegacyCopyAndReady() = runTest { + createProfiles("default", "failed-create") + preferenceRepository.insertDefaults("failed-create") + queries.enqueueProfileContextRecovery("default", "failed-create", 100) + queries.setActiveProfile("failed-create") + + migration.runRequiredMigrations() + + assertNull(queries.selectPendingProfileContextRecovery().executeAsOneOrNull()) + assertNull(queries.getProfileById("failed-create").executeAsOneOrNull()) + assertNull(queries.selectProfilePreferences("failed-create").executeAsOneOrNull()) + assertEquals("default", queries.getActiveProfile().executeAsOne().id) + assertEquals("default", assertIs(profiles.activeProfileContext.value).profile.id) + assertEquals(RequiredMigrationState.Ready, migration.requiredMigrationState.value) +} + +@Test +fun activeContextStaysSwitchingUntilPreferenceAndLocalSafetyCopyFinish() = runTest { + val copyStarted = CompletableDeferred() + val allowCopyToFinish = CompletableDeferred() + safetyStore.beforeFirstCopy = { + copyStarted.complete(Unit) + allowCopyToFinish.await() + } + + val migrationJob = launch { migration.runRequiredMigrations() } + copyStarted.await() + + assertIs(profiles.activeProfileContext.value) + assertEquals(RequiredMigrationState.Applying, migration.requiredMigrationState.value) + + allowCopyToFinish.complete(Unit) + migrationJob.join() + assertIs(profiles.activeProfileContext.value) +} +``` + +Add a contract test around `startupSurface(eulaAccepted, splashCompleted, migrationState)`: `Applying` and `Failed` never return `MAIN`; `Failed` returns `MIGRATION_RETRY`; only `Ready` can return `MAIN`. + +- [ ] **Step 2: Run migration tests and verify the red state** + +Run: + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest --tests "*LegacyProfilePreferencesReaderTest*" --tests "*ProfilePreferencesMigrationTest*" --tests "*MigrationManagerTest*" --console=plain +``` + +Expected: FAIL because required migration state and normalized snapshot reader are absent. + +- [ ] **Step 3: Implement a field-by-field legacy snapshot reader** + +```kotlin +data class LegacyProfilePreferenceSnapshot( + val core: CoreProfilePreferences, + val rack: RackPreferences, + val workout: WorkoutPreferences, + val led: LedPreferences, + val vbt: VbtPreferences, + val localSafety: ProfileLocalSafetyPreferences, +) + +interface LegacyProfilePreferencesReader { + fun readNormalized(): LegacyProfilePreferenceSnapshot +} +``` + +Read the current legacy keys through the existing `PreferencesManager` snapshot plus its existing rack/Just Lift/single-exercise APIs. Normalize independently: + +```kotlin +internal object LegacyProfilePreferenceKeys { + const val EQUIPMENT_RACK = "equipment_rack_items_v1" + const val JUST_LIFT = "just_lift_defaults" + const val EXERCISE_PREFIX = "exercise_defaults_" +} + +private fun normalizeBodyWeight(value: Float): Float = + value.takeIf { it.isFinite() && (it == 0f || it in 20f..300f) } ?: 0f + +private fun decodeLegacyRack(raw: String?): List = buildList { + val ids = mutableSetOf() + val elements = runCatching { raw?.let { json.parseToJsonElement(it).jsonArray } ?: JsonArray(emptyList()) } + .getOrElse { + Logger.w { "PROFILE_PREF_MIGRATION normalized legacy key equipment_rack_items_v1: malformed array" } + JsonArray(emptyList()) + } + elements.forEach { element -> + runCatching { json.decodeFromJsonElement(element) } + .getOrNull() + ?.takeIf { item -> item.id.isNotBlank() && item.name.isNotBlank() && item.weightKg.isFinite() && item.weightKg >= 0f && ids.add(item.id) } + ?.let(::add) + ?: Logger.w { "PROFILE_PREF_MIGRATION normalized legacy key equipment_rack_items_v1: invalid item" } + } +} + +private fun decodeSingleExerciseDefaults(): Map = + settings.keys + .asSequence() + .filter { it.startsWith(LegacyProfilePreferenceKeys.EXERCISE_PREFIX) } + .mapNotNull { key -> + val decoded = runCatching { + settings.getStringOrNull(key)?.let { json.decodeFromString(it) } + }.getOrNull() + if (decoded == null || decoded.exerciseId.isBlank()) { + Logger.w { "PROFILE_PREF_MIGRATION normalized legacy key $key: malformed defaults" } + null + } else { + Triple( + decoded.exerciseId, + decoded.toDocument(), + key == LegacyProfilePreferenceKeys.EXERCISE_PREFIX + decoded.exerciseId, + ) + } + } + .sortedByDescending { it.third } + .distinctBy { it.first } + .associate { it.first to it.second } +``` + +Share the three constants with `PreferencesManager` and the legacy rack implementation rather than leaving duplicate private literals. Decode Just Lift independently with `runCatching`, then apply the documented defaults for unknown enums, malformed JSON, non-finite/out-of-range numbers, and invalid nested entries. Preserve valid siblings. Log only the legacy key and reason, never a sensitive value. Force `vbt.enabled = true` regardless of subordinate legacy values. + +- [ ] **Step 4: Add an independent required migration state machine** + +```kotlin +sealed interface RequiredMigrationState { + data object NotStarted : RequiredMigrationState + data object Applying : RequiredMigrationState + data object Ready : RequiredMigrationState + data class Failed(val message: String) : RequiredMigrationState +} + +private const val KEY_PROFILE_PREFERENCES_MIGRATION_COMPLETE = + "profile_preferences_legacy_migration_complete_v1" + +private val _requiredMigrationState = MutableStateFlow(RequiredMigrationState.NotStarted) +val requiredMigrationState: StateFlow = _requiredMigrationState.asStateFlow() + +suspend fun runRequiredMigrations() = requiredMigrationMutex.withLock { + if (_requiredMigrationState.value == RequiredMigrationState.Ready) return@withLock + _requiredMigrationState.value = RequiredMigrationState.Applying + runCatching { migrateProfilePreferences() } + .onSuccess { _requiredMigrationState.value = RequiredMigrationState.Ready } + .onFailure { error -> + if (error is CancellationException) throw error + _requiredMigrationState.value = RequiredMigrationState.Failed(error.message ?: "Profile preference migration failed") + } +} + +suspend fun retryRequiredMigrations() = runRequiredMigrations() + +suspend fun awaitRequiredMigrations() { + requiredMigrationState.first { it is RequiredMigrationState.Ready } +} +``` + +The migration body must execute in this order: + +```kotlin +private suspend fun migrateProfilePreferences() { + userProfileRepository.ensureDefaultProfile() + profilePreferencesRepository.seedMissingProfiles() + userProfileRepository.recoverPendingProfileTransitionForStartup() + val profiles = userProfileRepository.allProfiles.value + val snapshot = legacyProfilePreferencesReader.readNormalized() + val migrationTime = currentTimeMillis() + profiles.forEach { profile -> + queries.applyLegacyProfilePreferences( + profile_id = profile.id, + body_weight_kg = snapshot.core.bodyWeightKg.toDouble(), + weight_unit = snapshot.core.weightUnit.name, + weight_increment = snapshot.core.weightIncrement.toDouble(), + equipment_rack_json = ProfilePreferencesCodec.encodeRack(snapshot.rack), + workout_preferences_json = ProfilePreferencesCodec.encodeWorkout(snapshot.workout), + led_color_scheme_id = snapshot.led.colorScheme.toLong(), + led_preferences_json = ProfilePreferencesCodec.encodeLed(snapshot.led), + vbt_enabled = 1, + vbt_preferences_json = ProfilePreferencesCodec.encodeVbt(snapshot.vbt.copy(enabled = true)), + migrated_at = migrationTime, + ) + } + if (!settings.getBoolean(KEY_PROFILE_PREFERENCES_MIGRATION_COMPLETE, false)) { + profileLocalSafetyStore.copyLegacyToProfiles(profiles.map { it.id }, snapshot.localSafety) + settings.putBoolean(KEY_PROFILE_PREFERENCES_MIGRATION_COMPLETE, true) + } + userProfileRepository.retryPendingLocalCleanup() + userProfileRepository.reconcileActiveProfileContext() +} +``` + +Register the reader and expanded migration constructor in the same task that introduces them so Android/iOS startup never resolves a partial graph: + +```kotlin +single { SettingsLegacyProfilePreferencesReader(get(), get()) } +single { + MigrationManager( + database = get(), + userProfileRepository = get(), + gamificationRepository = get(), + settings = get(), + profilePreferencesRepository = get(), + profileLocalSafetyStore = get(), + legacyProfilePreferencesReader = get(), + ) +} +``` + +Replace the existing `MigrationManager` binding; do not add a duplicate. Task 9 verifies this binding with the complete final graph. + +`applyLegacyProfilePreferences` must retain its SQL `WHERE legacy_migration_version = 0`; that is the retry guard. The startup-only recovery drains any transition journal left by a killed process before the profile list is snapshotted but deliberately leaves `activeProfileContext` in `Switching`; only the final reconciliation publishes migrated values as `Ready`. A recovery/reconciliation failure keeps the required migration in `Failed`, so Retry continues the same idempotent journal operation. Start ordinary non-critical repair passes only after required migration reaches `Ready`. + +Make the existing platform startup entry point idempotently start the required stage first: + +```kotlin +fun checkAndRunMigrations() { + scope.launch { + runRequiredMigrations() + if (requiredMigrationState.value == RequiredMigrationState.Ready) { + runNonCriticalRepairsNow() + } + } +} +``` + +Keep Android's `VitruvianApp.onCreate()` and iOS `KoinInit.runMigrations()` calling `checkAndRunMigrations`; update their comments so required preference migration is no longer described as best effort. `AppContent` observes the same state and its idempotent call guarantees the gate is started in previews or alternate hosts. + +- [ ] **Step 5: Gate root navigation and expose Retry on both platforms** + +Inject `MigrationManager` through `AndroidAppHost` and the retained `IosAppDependencies`, pass it into `AppContent`, and collect `requiredMigrationState` there. Start it once with `LaunchedEffect(migrationManager)`. + +```kotlin +internal enum class StartupSurface { EULA, SPLASH, MIGRATION_RETRY, MAIN } + +internal fun startupSurface( + eulaAccepted: Boolean, + splashCompleted: Boolean, + migrationState: RequiredMigrationState, +): StartupSurface = when { + !eulaAccepted -> StartupSurface.EULA + migrationState is RequiredMigrationState.Failed -> StartupSurface.MIGRATION_RETRY + migrationState != RequiredMigrationState.Ready || !splashCompleted -> StartupSurface.SPLASH + else -> StartupSurface.MAIN +} + +@Composable +private fun MigrationRetryScreen(message: String, onRetry: () -> Unit) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column( + modifier = Modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(message, textAlign = TextAlign.Center) + Spacer(Modifier.height(16.dp)) + Button(onClick = onRetry) { Text(stringResource(Res.string.action_retry)) } + } + } +} + +val migrationState by migrationManager.requiredMigrationState.collectAsState() +LaunchedEffect(migrationManager) { migrationManager.runRequiredMigrations() } + +when { + !eulaAccepted -> EulaScreen(onAccept = eulaViewModel::acceptEula) + migrationState is RequiredMigrationState.Failed -> MigrationRetryScreen( + message = (migrationState as RequiredMigrationState.Failed).message, + onRetry = { scope.launch { migrationManager.retryRequiredMigrations() } }, + ) + migrationState != RequiredMigrationState.Ready || showLaunchSplash -> SplashScreen(visible = true) + else -> EnhancedMainScreen( + viewModel = mainViewModel, + exerciseRepository = exerciseRepository, + themeMode = themeMode, + onThemeModeChange = themeViewModel::setThemeMode, + dynamicColorAvailable = dynamicColorAvailable, + dynamicColorEnabled = dynamicColorEnabled, + onDynamicColorEnabledChange = themeViewModel::setDynamicColorEnabled, + ) +} +``` + +Keep the existing 2.5-second splash minimum, but readiness and the timer are independent conditions. The Retry surface must not expose bottom navigation or workout actions. + +- [ ] **Step 6: Pass migration and gate tests** + +Run: + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest --tests "*LegacyProfilePreferencesReaderTest*" --tests "*ProfilePreferencesMigrationTest*" --tests "*MigrationManagerTest*" --console=plain +``` + +Expected: PASS for reconciliation seeding, all-profile copy, corrupt normalization, unchanged active profile, partial failure/retry, new-profile defaults, `vbtEnabled=true`, and navigation gating. + +- [ ] **Step 7: Commit the required migration gate** + +```powershell +git add shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/LegacyProfilePreferencesReader.kt shared/src/commonTest/kotlin/com/devil/phoenixproject/data/preferences/LegacyProfilePreferencesReaderTest.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/PreferencesManager.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/EquipmentRackRepository.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/data/migration/MigrationManager.kt shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/migration/MigrationManagerTest.kt shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/migration/ProfilePreferencesMigrationTest.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/di/DataModule.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/di/DomainModule.kt androidApp/src/main/kotlin/com/devil/phoenixproject/VitruvianApp.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/di/KoinInit.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/App.kt shared/src/androidMain/kotlin/com/devil/phoenixproject/AndroidAppHost.kt shared/src/iosMain/kotlin/com/devil/phoenixproject/IosAppHost.kt +git commit -m "feat: migrate legacy profile preferences at startup" +``` + +### Task 5: Route compatibility managers and direct consumers through the active profile + +**Files:** +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/PreferencesManager.kt` +- Create: `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/migration/RequiredMigrationGate.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/migration/MigrationManager.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/SettingsManager.kt` +- Modify: `shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/manager/SettingsManagerTest.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/EquipmentRackRepository.kt` +- Modify: `shared/src/commonTest/kotlin/com/devil/phoenixproject/data/repository/EquipmentRackRepositoryTest.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/integration/HealthBodyWeightSyncManager.kt` +- Modify: `shared/src/commonTest/kotlin/com/devil/phoenixproject/data/integration/HealthBodyWeightSyncManagerTest.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/voice/SafeWordDetectionManager.kt` +- Create: `shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/voice/SafeWordDetectionManagerTest.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/DefaultWorkoutSessionManager.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/BleConnectionManager.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/MainViewModel.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/SettingsTab.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/navigation/NavGraph.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/di/DataModule.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/di/DomainModule.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/di/SyncModule.kt` +- Modify: `shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeUserProfileRepository.kt` +- Modify: `shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/DWSMTestHarness.kt` +- Modify: `shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/viewmodel/MainViewModelTest.kt` +- Modify: `shared/src/androidHostTest/kotlin/com/devil/phoenixproject/e2e/WorkoutFlowE2ETest.kt` +- Modify affected tests and fakes found with `rg -n "PreferencesManager|SettingsManager|EquipmentRackRepository" shared/src/commonTest shared/src/androidHostTest`. + +**Interfaces:** +- Consumes: Task 3's active `Ready` context and whole-section update methods. +- Produces: a compatibility `UserPreferences` stream with global and active-profile fields, with no profile setter writing legacy Settings. + +- [ ] **Step 1: Write failing A/B consumer tests** + +```kotlin +@Test +fun profileSetterWritesRepositoryAndLeavesLegacyStoreUntouched() = runTest { + legacySettings.putFloat("body_weight_kg", 70f) + settingsManager.setBodyWeightKg(82f) + advanceUntilIdle() + + val ready = assertIs(profileRepository.activeProfileContext.value) + assertEquals(82f, ready.preferences.core.value.bodyWeightKg) + assertEquals(70f, legacySettings.getFloat("body_weight_kg", 0f)) +} + +@Test +fun equipmentRackFollowsActiveProfile() = runTest { + setActive("a") + rackRepository.saveItems(listOf(rackItem("a-item"))) + setActive("b") + rackRepository.saveItems(listOf(rackItem("b-item"))) + + assertEquals(listOf("b-item"), rackRepository.getItems().map { it.id }) + setActive("a") + assertEquals(listOf("a-item"), rackRepository.getItems().map { it.id }) +} + +@Test +fun healthImportWaitsForRequiredMigrationAndWritesActiveCore() = runTest { + val result = async { manager.syncLatestFromConnectedPlatform() } + runCurrent() + assertEquals(0f, activeCore().bodyWeightKg) + migrationGate.state.value = RequiredMigrationState.Ready + assertIs(result.await()) + assertEquals(81f, activeCore().bodyWeightKg) +} +``` + +Add tests that voice stop is effectively false when intent is true but the active profile lacks a calibrated phrase, Just Lift/default exercise settings change on profile switch, active-profile setter cascades preserve the existing adult-consent invariants, and BLE reconnect/profile switch receives the active LED scheme. Task 6 owns the final runtime assertion that persisted adult-mode intent remains ineffective without local confirmation. + +Add this start-gate regression: + +```kotlin +@Test +fun workoutStartIsRejectedWhileProfileContextIsSwitching() = runTest { + profileRepository.recoverPendingProfileTransitionForStartup() + engine.startWorkout() + advanceUntilIdle() + + assertIs(engine.coordinator.workoutState.value) + assertEquals(0, fakeBleRepository.workoutParameters.size) +} +``` + +- [ ] **Step 2: Run the consumer tests and confirm legacy behavior fails isolation** + +Run: + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest --tests "*SettingsManagerTest*" --tests "*EquipmentRackRepositoryTest*" --tests "*HealthBodyWeightSyncManagerTest*" --tests "*SafeWordDetectionManagerTest*" --tests "*BleConnectionManagerTest*" --tests "*ActiveSessionEngineIntegrationTest*" --console=plain +``` + +Expected: FAIL because current setters and rack/body-weight consumers use global `PreferencesManager`. + +- [ ] **Step 3: Make `SettingsManager` a two-source compatibility facade** + +```kotlin +class SettingsManager( + private val globalPreferences: PreferencesManager, + private val userProfileRepository: UserProfileRepository, + private val scope: CoroutineScope, +) { + val userPreferences: StateFlow = combine( + globalPreferences.preferencesFlow, + userProfileRepository.activeProfileContext.filterIsInstance(), + ) { global, ready -> + global.copy( + weightUnit = ready.preferences.core.value.weightUnit, + weightIncrement = ready.preferences.core.value.weightIncrement, + bodyWeightKg = ready.preferences.core.value.bodyWeightKg, + stopAtTop = ready.preferences.workout.value.stopAtTop, + beepsEnabled = ready.preferences.workout.value.beepsEnabled, + stallDetectionEnabled = ready.preferences.workout.value.stallDetectionEnabled, + audioRepCountEnabled = ready.preferences.workout.value.audioRepCountEnabled, + repCountTiming = ready.preferences.workout.value.repCountTiming, + summaryCountdownSeconds = ready.preferences.workout.value.summaryCountdownSeconds, + autoStartCountdownSeconds = ready.preferences.workout.value.autoStartCountdownSeconds, + gamificationEnabled = ready.preferences.workout.value.gamificationEnabled, + autoStartRoutine = ready.preferences.workout.value.autoStartRoutine, + countdownBeepsEnabled = ready.preferences.workout.value.countdownBeepsEnabled, + repSoundEnabled = ready.preferences.workout.value.repSoundEnabled, + motionStartEnabled = ready.preferences.workout.value.motionStartEnabled, + weightSuggestionsEnabled = ready.preferences.workout.value.weightSuggestionsEnabled, + defaultRoutineExerciseUsePercentOfPR = ready.preferences.workout.value.defaultRoutineExerciseUsePercentOfPR, + defaultRoutineExerciseWeightPercentOfPR = ready.preferences.workout.value.defaultRoutineExerciseWeightPercentOfPR, + voiceStopEnabled = ready.preferences.workout.value.voiceStopEnabled, + safeWord = ready.localSafety.safeWord, + safeWordCalibrated = ready.localSafety.safeWordCalibrated, + colorScheme = ready.preferences.led.value.colorScheme, + discoModeUnlocked = ready.preferences.led.value.discoModeUnlocked, + vbtEnabled = ready.preferences.vbt.value.enabled, + velocityLossThresholdPercent = ready.preferences.vbt.value.velocityLossThresholdPercent, + autoEndOnVelocityLoss = ready.preferences.vbt.value.autoEndOnVelocityLoss, + defaultScalingBasis = ready.preferences.vbt.value.defaultScalingBasis, + verbalEncouragementEnabled = ready.preferences.vbt.value.verbalEncouragementEnabled, + vulgarModeEnabled = ready.preferences.vbt.value.vulgarModeEnabled, + vulgarTier = ready.preferences.vbt.value.vulgarTier, + dominatrixModeUnlocked = ready.preferences.vbt.value.dominatrixModeUnlocked, + dominatrixModeActive = ready.preferences.vbt.value.dominatrixModeActive, + adultsOnlyConfirmed = ready.localSafety.adultsOnlyConfirmed, + adultsOnlyPrompted = ready.localSafety.adultsOnlyPrompted, + ) + }.stateIn(scope, SharingStarted.Eagerly, globalPreferences.preferencesFlow.value) +} +``` + +Implement each profile setter as a serialized mutation of the active typed section. Capture the originating profile ID before launching, re-read the latest section after taking its section mutex, and verify the same profile is still active before writing. This prevents both an A-originated action from landing in B and queued same-section setters from overwriting one another with an old whole-section snapshot. Keep theme/video/language/backup/BLE-compatibility/backfill setters delegated to `globalPreferences`. Keep legacy profile getters only for migration; mark them `@Deprecated("Legacy migration read only")` where call-site churn permits. Hardware LED application belongs to `BleConnectionManager`, so `SettingsManager` no longer takes `BleRepository`. + +```kotlin +private val coreUpdates = Mutex() +private val workoutUpdates = Mutex() +private val ledUpdates = Mutex() +private val vbtAndSafetyUpdates = Mutex() + +private fun ready(): ActiveProfileContext.Ready = + userProfileRepository.activeProfileContext.value as? ActiveProfileContext.Ready + ?: throw ProfileContextUnavailableException() + +private fun readyFor(expectedId: String): ActiveProfileContext.Ready { + val current = ready() + if (current.profile.id != expectedId) { + throw StaleProfileContextException(expectedId, current.profile.id) + } + return current +} + +private fun updateSection( + mutex: Mutex, + read: (ActiveProfileContext.Ready) -> T, + write: suspend (String, T) -> Unit, + transform: (ActiveProfileContext.Ready, T) -> T?, +) { + val expectedId = (userProfileRepository.activeProfileContext.value as? ActiveProfileContext.Ready) + ?.profile?.id + ?: run { + Logger.w { "Profile preference update ignored while switching" } + return + } + scope.launch { + try { + mutex.withLock { + val current = readyFor(expectedId) + val next = transform(current, read(current)) ?: return@withLock + write(expectedId, next) + } + } catch (error: CancellationException) { + throw error + } catch (error: ProfileContextUnavailableException) { + Logger.w(error) { "Profile preference update skipped while switching" } + } catch (error: StaleProfileContextException) { + Logger.w(error) { "Profile preference update skipped after profile switch" } + } + } +} + +private fun updateCore(transform: (CoreProfilePreferences) -> CoreProfilePreferences) = + updateSection(coreUpdates, { it.preferences.core.value }, userProfileRepository::updateCore) { _, value -> transform(value) } + +private fun updateWorkout(transform: (WorkoutPreferences) -> WorkoutPreferences) = + updateSection(workoutUpdates, { it.preferences.workout.value }, userProfileRepository::updateWorkout) { _, value -> transform(value) } + +private fun updateLed(transform: (LedPreferences) -> LedPreferences) = + updateSection(ledUpdates, { it.preferences.led.value }, userProfileRepository::updateLed) { _, value -> transform(value) } + +private fun updateVbt(transform: (ActiveProfileContext.Ready, VbtPreferences) -> VbtPreferences?) = + updateSection(vbtAndSafetyUpdates, { it.preferences.vbt.value }, userProfileRepository::updateVbt, transform) + +private fun updateSafety(transform: (ActiveProfileContext.Ready, ProfileLocalSafetyPreferences) -> ProfileLocalSafetyPreferences) = + updateSection(vbtAndSafetyUpdates, { it.localSafety }, userProfileRepository::updateLocalSafety, transform) + +fun setWeightUnit(value: WeightUnit) = updateCore { it.copy(weightUnit = value) } +fun setWeightIncrement(value: Float) = updateCore { it.copy(weightIncrement = value) } +fun setBodyWeightKg(value: Float) = updateCore { it.copy(bodyWeightKg = value) } +fun setStopAtTop(value: Boolean) = updateWorkout { it.copy(stopAtTop = value) } +fun setBeepsEnabled(value: Boolean) = updateWorkout { it.copy(beepsEnabled = value) } +fun setStallDetectionEnabled(value: Boolean) = updateWorkout { it.copy(stallDetectionEnabled = value) } +fun setAudioRepCountEnabled(value: Boolean) = updateWorkout { it.copy(audioRepCountEnabled = value) } +fun setRepCountTiming(value: RepCountTiming) = updateWorkout { it.copy(repCountTiming = value) } +fun setSummaryCountdownSeconds(value: Int) = updateWorkout { it.copy(summaryCountdownSeconds = value) } +fun setAutoStartCountdownSeconds(value: Int) = updateWorkout { it.copy(autoStartCountdownSeconds = value) } +fun setGamificationEnabled(value: Boolean) = updateWorkout { it.copy(gamificationEnabled = value) } +fun setAutoStartRoutine(value: Boolean) = updateWorkout { it.copy(autoStartRoutine = value) } +fun setCountdownBeepsEnabled(value: Boolean) = updateWorkout { it.copy(countdownBeepsEnabled = value) } +fun setRepSoundEnabled(value: Boolean) = updateWorkout { it.copy(repSoundEnabled = value) } +fun setMotionStartEnabled(value: Boolean) = updateWorkout { it.copy(motionStartEnabled = value) } +fun setWeightSuggestionsEnabled(value: Boolean) = updateWorkout { it.copy(weightSuggestionsEnabled = value) } +fun setDefaultRoutineExerciseUsePercentOfPR(value: Boolean) = updateWorkout { it.copy(defaultRoutineExerciseUsePercentOfPR = value) } +fun setDefaultRoutineExerciseWeightPercentOfPR(value: Int) = updateWorkout { it.copy(defaultRoutineExerciseWeightPercentOfPR = value.coerceIn(50, 120)) } +fun setVoiceStopEnabled(value: Boolean) = updateWorkout { it.copy(voiceStopEnabled = value) } +fun setColorScheme(value: Int) = updateLed { it.copy(colorScheme = value) } +fun setDiscoModeUnlocked(value: Boolean) = updateLed { it.copy(discoModeUnlocked = value) } +fun setVbtEnabled(value: Boolean) = updateVbt { _, current -> current.copy(enabled = value) } +fun setVelocityLossThreshold(value: Int) = updateVbt { _, current -> current.copy(velocityLossThresholdPercent = value.coerceIn(10, 50)) } +fun setAutoEndOnVelocityLoss(value: Boolean) = updateVbt { _, current -> current.copy(autoEndOnVelocityLoss = value) } +fun setDefaultScalingBasis(value: ScalingBasis) = updateVbt { _, current -> current.copy(defaultScalingBasis = value) } +fun setVerbalEncouragementEnabled(value: Boolean) = updateVbt { _, current -> + if (value) current.copy(verbalEncouragementEnabled = true) + else current.copy(verbalEncouragementEnabled = false, vulgarModeEnabled = false, dominatrixModeActive = false) +} +fun setVulgarModeEnabled(value: Boolean) = updateVbt { context, current -> + when { + value && !context.localSafety.adultsOnlyPrompted -> null + !value -> current.copy(vulgarModeEnabled = false, dominatrixModeActive = false) + else -> current.copy(vulgarModeEnabled = true) + } +} +fun setVulgarTier(value: VulgarTier) = updateVbt { _, current -> current.copy(vulgarTier = value) } +fun setDominatrixModeUnlocked(value: Boolean) = updateVbt { _, current -> current.copy(dominatrixModeUnlocked = value) } +fun setDominatrixModeActive(value: Boolean) = updateVbt { context, current -> + if (value && (!current.dominatrixModeUnlocked || !current.vulgarModeEnabled || !context.localSafety.adultsOnlyConfirmed)) null + else current.copy(dominatrixModeActive = value) +} +fun setSafeWord(value: String?) = updateSafety { _, current -> current.copy(safeWord = value) } +fun setSafeWordCalibrated(value: Boolean) = updateSafety { _, current -> current.copy(safeWordCalibrated = value) } +fun setAdultsOnlyConfirmed(value: Boolean) = updateSafety { _, current -> + current.copy(adultsOnlyConfirmed = value, adultsOnlyPrompted = true) +} +fun isAdultsOnlyPrompted(): Boolean = ready().localSafety.adultsOnlyPrompted +fun setAdultsOnlyPrompted(value: Boolean) = updateSafety { _, current -> current.copy(adultsOnlyPrompted = value) } + +fun confirmAdultsAndEnableVulgar() { + val expectedId = (userProfileRepository.activeProfileContext.value as? ActiveProfileContext.Ready) + ?.profile?.id ?: return + scope.launch { + try { + vbtAndSafetyUpdates.withLock { + val before = readyFor(expectedId) + userProfileRepository.updateLocalSafety( + expectedId, + before.localSafety.copy(adultsOnlyConfirmed = true, adultsOnlyPrompted = true), + ) + val afterSafety = readyFor(expectedId) + userProfileRepository.updateVbt( + expectedId, + afterSafety.preferences.vbt.value.copy(vulgarModeEnabled = true), + ) + } + } catch (error: CancellationException) { + throw error + } catch (error: ProfileContextUnavailableException) { + Logger.w(error) { "Adult consent update stopped while switching" } + } catch (error: StaleProfileContextException) { + Logger.w(error) { "Adult consent update stopped after profile switch" } + } + } +} +``` + +Keep the existing clamp/cascade regression behavior in `SettingsManagerTest`: routine default percentage `50..120`; velocity threshold `10..50`; verbal-off clears vulgar and active Dominatrix; vulgar-off clears active Dominatrix; vulgar-on requires `adultsOnlyPrompted`; Dominatrix-on requires unlocked + vulgar intent + local confirmation; confirmation atomically implies prompted; prompted-only never changes confirmation. Disabling `vbtEnabled` preserves every subordinate VBT value. Expose `MainViewModel.confirmAdultsAndEnableVulgar`, add one `SettingsTab` callback for it, wire that callback in `NavGraph`, and replace the dialog's immediate confirmed → prompted → vulgar callback trio with the composite call. Add a queued/concurrent regression proving the composite never reads stale consent or enables profile B. The legacy `VerbalEncouragementPreferenceCascadeTest` remains unchanged as migration-source coverage; port the equivalent active-profile facade assertions into `SettingsManagerTest`. + +- [ ] **Step 4: Make rack, body-weight, quick-start, safety, and LED consumers active-profile aware** + +Use these concrete boundaries: + +```kotlin +interface RequiredMigrationGate { + val requiredMigrationState: StateFlow + suspend fun awaitRequiredMigrations() +} + +class MigrationManager(/* existing required dependencies */) : RequiredMigrationGate { + override val requiredMigrationState: StateFlow = /* existing state */ + override suspend fun awaitRequiredMigrations() { /* existing implementation */ } +} + +class ProfileEquipmentRackRepository( + private val profiles: UserProfileRepository, + private val scope: CoroutineScope, +) : EquipmentRackRepository { + private val mutations = Mutex() + + private fun items(context: ActiveProfileContext): List = + (context as? ActiveProfileContext.Ready)?.preferences?.rack?.value?.items.orEmpty() + + override val rackItems: StateFlow> = profiles.activeProfileContext + .map(::items) + .distinctUntilChanged() + .stateIn(scope, SharingStarted.Eagerly, items(profiles.activeProfileContext.value)) + + private fun ready(): ActiveProfileContext.Ready = + profiles.activeProfileContext.value as? ActiveProfileContext.Ready + ?: throw ProfileContextUnavailableException() + + private fun readyFor(expectedId: String): ActiveProfileContext.Ready { + val current = ready() + if (current.profile.id != expectedId) { + throw StaleProfileContextException(expectedId, current.profile.id) + } + return current + } + + private suspend fun mutate( + expectedId: String, + transform: (RackPreferences) -> RackPreferences, + ) = mutations.withLock { + val current = readyFor(expectedId) + profiles.updateRack(expectedId, transform(current.preferences.rack.value)) + } + + override suspend fun getItems(): List = ready().preferences.rack.value.items + + override suspend fun saveItems(items: List) { + val expectedId = ready().profile.id + mutate(expectedId) { it.copy(items = items) } + } + + override suspend fun upsert(item: RackItem) { + val expectedId = ready().profile.id + mutate(expectedId) { current -> + val items = current.items.toMutableList() + val index = items.indexOfFirst { it.id == item.id } + if (index >= 0) items[index] = item else items += item + current.copy(items = items) + } + } + + override suspend fun delete(id: String) { + val expectedId = ready().profile.id + mutate(expectedId) { current -> current.copy(items = current.items.filterNot { it.id == id }) } + } + + override suspend fun resolveActiveItems(selection: ActiveRackSelection): List { + val byId = getItems().filter { it.enabled }.associateBy { it.id } + return selection.distinctItemIds.mapNotNull(byId::get) + } + + fun close() = scope.cancel() +} + +private suspend fun awaitReadyProfile(): ActiveProfileContext.Ready { + requiredMigrationGate.awaitRequiredMigrations() + return userProfileRepository.activeProfileContext.value as? ActiveProfileContext.Ready + ?: throw ProfileContextUnavailableException() +} +``` + +Keep `SettingsEquipmentRackRepository` as the explicit legacy implementation, but replace its production `DataModule` binding with a lifecycle-cleaned singleton: + +```kotlin +single { + ProfileEquipmentRackRepository( + profiles = get(), + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + ) +} onClose { repository -> + (repository as? ProfileEquipmentRackRepository)?.close() +} +``` + +Tests pass `backgroundScope` explicitly so no eager collector survives the test. Bind `single { get() }` in `DomainModule`. Replace `PreferencesManager` in `HealthBodyWeightSyncManager` with `RequiredMigrationGate`, and use named arguments in `SyncModule` so the changed same-arity constructor cannot silently swap dependencies. + +Call `awaitReadyProfile()` at the start of the existing `syncLatestFromConnectedPlatform()` flow, use its profile ID instead of falling back to `"default"`, and replace the global write with: + +```kotlin +userProfileRepository.updateCore( + ready.profile.id, + ready.preferences.core.value.copy(bodyWeightKg = sample.weightKg), +) +externalMeasurementRepository.upsertMeasurements(listOf(measurement)) +``` + +Perform the core update before inserting `ExternalBodyMeasurement`, and convert `StaleProfileContextException` into `HealthBodyWeightSyncResult.Failed` so a concurrent switch never writes the new profile. + +Change `SafeWordDetectionManager` to consume `ActiveProfileContext.Ready`: voice-stop intent, a nonblank local phrase, and local calibration must all agree before the listener starts. Because `SafeWordListener` is a final expect/actual class, keep the new common test at the factory boundary: a factory whose `create` body increments a counter then throws proves invalid effective state never invokes it and a fully valid active context does invoke it, without introducing a production listener abstraction solely for tests. Existing platform listener tests remain unchanged. Change `DefaultWorkoutSessionManager` and `ActiveSessionEngine` quick-start reads to `SettingsManager.userPreferences` or the ready workout section. Change MainViewModel's disco unlock write to `updateLed`. + +Move hardware color application entirely into `BleConnectionManager`; `SettingsManager.setColorScheme` only persists the active LED section. Replace the connect-only snapshot with a combined collector so both reconnect and profile switch reapply the correct scheme: + +```kotlin +scope.launch { + try { + combine( + bleRepository.connectionState, + settingsManager.userPreferences.map { it.colorScheme }, + ) { connection, color -> + (connection is ConnectionState.Connected) to color + } + .distinctUntilChanged() + .collect { (connected, color) -> + bleRepository.setLastColorSchemeIndex(color) + if (connected) { + bleRepository.setColorScheme(color).onFailure { error -> + Logger.e(error) { "Failed to apply active profile LED color" } + } + } + } + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + Logger.e(error) { "Error collecting active profile LED color" } + } +} +``` + +Keeping disconnected `(false, color)` emissions ensures reconnecting with the same color still emits `(true, color)` and reapplies it. + +Insert this as the first executable block in the existing `ActiveSessionEngine.startWorkout` method, before logging or coordinator mutation: + +```kotlin +if (userProfileRepository.activeProfileContext.value !is ActiveProfileContext.Ready) { + Logger.w { "Workout start ignored while profile context is switching" } + return +} +``` + +Expose document-level quick-start accessors on `SettingsManager` so saves reuse its profile-ID capture and serialized workout mutation: + +```kotlin +fun getSingleExerciseDefaultsDocument(exerciseId: String): SingleExerciseDefaultsDocument? = + ready().preferences.workout.value.singleExerciseDefaults[exerciseId] + +fun saveSingleExerciseDefaultsDocument(document: SingleExerciseDefaultsDocument) = + updateWorkout { current -> + current.copy(singleExerciseDefaults = current.singleExerciseDefaults + (document.exerciseId to document)) + } + +fun getJustLiftDefaultsDocument(): JustLiftDefaultsDocument = + ready().preferences.workout.value.justLiftDefaults + +fun saveJustLiftDefaultsDocument(document: JustLiftDefaultsDocument) = + updateWorkout { current -> current.copy(justLiftDefaults = document) } +``` + +Map runtime values explicitly at the `ActiveSessionEngine` boundary, reusing the existing complete single-exercise converters in `ProfileWorkoutDefaultsMapper.kt`: + +```kotlin +suspend fun getSingleExerciseDefaults(exerciseId: String): SingleExerciseDefaults? = + settingsManager.getSingleExerciseDefaultsDocument(exerciseId)?.toLegacySingleExerciseDefaults() + +fun saveSingleExerciseDefaults(defaults: SingleExerciseDefaults) = + settingsManager.saveSingleExerciseDefaultsDocument(defaults.toDocument()) + +suspend fun getJustLiftDefaults(): JustLiftDefaults = + settingsManager.getJustLiftDefaultsDocument().toRuntimeJustLiftDefaults() + +fun saveJustLiftDefaults(defaults: JustLiftDefaults) = + settingsManager.saveJustLiftDefaultsDocument(defaults.toDocument()) + +private fun JustLiftDefaults.toDocument() = JustLiftDefaultsDocument( + workoutModeId = workoutModeId, + weightPerCableKg = weightPerCableKg, + weightChangePerRep = weightChangePerRep.toFloat(), + eccentricLoadPercentage = eccentricLoadPercentage, + echoLevelValue = echoLevelValue, + stallDetectionEnabled = stallDetectionEnabled, + repCountTimingName = repCountTimingName, + restSeconds = restSeconds, +) + +private fun JustLiftDefaultsDocument.toRuntimeJustLiftDefaults() = JustLiftDefaults( + workoutModeId = workoutModeId, + weightPerCableKg = weightPerCableKg, + weightChangePerRep = weightChangePerRep.roundToInt(), + eccentricLoadPercentage = eccentricLoadPercentage, + echoLevelValue = echoLevelValue, + stallDetectionEnabled = stallDetectionEnabled, + repCountTimingName = repCountTimingName, + restSeconds = restSeconds, +) +``` + +Route `saveJustLiftDefaultsFromWorkout` and `saveSingleExerciseDefaultsFromWorkout` through the same document accessors; no active-profile quick-start path may call legacy `PreferencesManager`. Tests assert lossless round trips, including rack item IDs and per-set arrays. The Just Lift display-unit increment intentionally rounds at the existing runtime boundary, matching its current `Int` type. + +- [ ] **Step 5: Update direct construction sites and pass focused tests** + +Update MainViewModel's direct construction to `SettingsManager(preferencesManager, userProfileRepository, viewModelScope)`. Initialize a single shared `FakeUserProfileRepository().apply { setActiveProfileForTest() }` in the DWSM harness, MainViewModel tests, workout E2E tests, ActiveSessionEngine fixtures, SafeWord fixtures, and BLE fixtures; pass that same ready fake to every profile-aware collaborator in the fixture. Use `recoverPendingProfileTransitionForStartup()` when a test specifically needs `Switching`. Remove profile-owned writes from `PreferencesManager`; retain the old keys and snapshot-reading APIs for Task 4 migration. Keep `SettingsEquipmentRackRepository` available but unbound in production. + +Run: + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest --tests "*SettingsManagerTest*" --tests "*SettingsPreferencesManagerTest*" --tests "*EquipmentRackRepositoryTest*" --tests "*HealthBodyWeightSyncManagerTest*" --tests "*SafeWordDetectionManagerTest*" --tests "*BleConnectionManagerTest*" --tests "*ActiveSessionEngineIntegrationTest*" --tests "*MainViewModelTest*" --tests "*WorkoutFlowE2ETest*" --console=plain +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest --tests "*DWSM*" --tests "*BodyweightRackLoadTest*" --tests "*CycleRoutineLoadFallbackTest*" --tests "*Issue593BodyweightRepEntryTest*" --tests "*WarmupProgressionTest*" --tests "*WarmupTransitionToneTest*" --tests "*WeightRecommendationIntegrationTest*" --console=plain +.\gradlew.bat '-Pskip.supabase.check=true' :shared:compileAndroidMain :shared:testAndroidHostTest --tests "*KoinModuleVerifyTest*" --console=plain +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest --console=plain +``` + +Expected: PASS; A/B consumers change immediately, stale queued actions never mutate the newly active profile, same-section setters do not lose sibling changes, legacy values remain unchanged after profile setters, the production Koin graph resolves the new gate/rack/health boundaries, and the unfiltered host suite catches every shared fake/constructor call site. + +- [ ] **Step 6: Commit active-profile consumer routing** + +```powershell +git add shared/src/commonMain/kotlin/com/devil/phoenixproject/data/migration/RequiredMigrationGate.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/data/migration/MigrationManager.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/PreferencesManager.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/SettingsManager.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/EquipmentRackRepository.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/data/integration/HealthBodyWeightSyncManager.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/voice/SafeWordDetectionManager.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/DefaultWorkoutSessionManager.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/BleConnectionManager.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/MainViewModel.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/SettingsTab.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/navigation/NavGraph.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/di/DataModule.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/di/DomainModule.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/di/SyncModule.kt shared/src/commonTest shared/src/androidHostTest +git commit -m "refactor: route training settings through active profile" +``` + +### Task 6: Gate live VBT behavior with the profile-owned master toggle + +**Files:** +- Create: `shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/VbtEnabledRuntimeTest.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutCoordinator.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/DefaultWorkoutSessionManager.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutUiState.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ActiveWorkoutScreen.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutTab.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutHud.kt` +- Inspect only: `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/BiomechanicsHistoryCard.kt` +- Modify: `shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/DWSMTestHarness.kt` +- Modify: `shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutCoordinatorEventTest.kt` +- Modify: `shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngineIntegrationTest.kt` +- Modify: `shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/VerbalEncouragementGateTest.kt` + +**Interfaces:** +- Consumes: the complete VBT portion of `SettingsManager.userPreferences` for the active `Ready` profile. +- Produces: one immutable `VbtRuntimeSettings(enabled, velocityLossThresholdPercent, autoEndOnVelocityLoss)` snapshot in `WorkoutCoordinator`, updated as a unit and read once per completed rep. +- Produces: `WorkoutUiState.vbtEnabled`, threaded through the complete active-workout Compose call chain. +- Does not add a Koin binding or gate raw biomechanics capture, summaries, repository persistence, PR/progression inputs, or history. + +- [ ] **Step 1: Characterize and expose the real engine seam without changing behavior** + +The existing runtime path is `ActiveSessionEngine.processBiomechanicsForRep()` -> +`BiomechanicsEngine.processRep()` -> `checkVelocityThreshold()`. Do not invent +`applyProfileVbt`, `processWorkingRep`, `WorkoutEvent`, or a test-local decision tracker. + +First run the existing VBT tests. Then make only a behavior-neutral extraction: + +- keep `processBiomechanicsForRep()` as the unconditional capture path; +- expose the existing threshold decision as an `internal suspend` production method such as + `evaluateLatestVbtResult()` so common tests can drive the real `BiomechanicsEngine` and real + `HapticEvent` stream; +- have the production rep path call that same method after `BiomechanicsEngine.processRep()`; and +- keep the warm-up guard in the production decision path. + +Run the existing VBT tests again and require the same baseline behavior before adding the master toggle. + +- [ ] **Step 2: Write failing production-backed disabled/re-enabled tests** + +Use `DWSMTestHarness`, its one `Ready` fake profile, the real coordinator +`BiomechanicsEngine`, real `WorkoutState`, real `HapticEvent` collection, and +`fakeBiomechanicsRepo`. Helpers may construct uniform `WorkoutMetric` samples, but must not +reimplement the VBT decision state machine. + +Cover all of the following: + +1. With `vbtEnabled=false`, threshold and auto-end still configured, processing baseline and + threshold-crossing reps leaves `latestRepResult` and `getSetSummary().repResults` populated, + emits neither `VELOCITY_THRESHOLD_REACHED` nor `VERBAL_ENCOURAGEMENT`, and does not auto-end + the active set. +2. Completing that disabled-VBT set still writes its rep results to + `FakeBiomechanicsRepository.savedBiomechanics`. +3. Re-enabling changes only the master: the configured threshold remains unchanged in + `BiomechanicsEngine.currentVelocityLossThresholdPercent`, auto-end remains configured, and + subsequent threshold reps restore the existing alert/auto-end behavior. +4. A `Ready` profile switch applies the new profile's enabled/threshold/auto-end triple as one + `VbtRuntimeSettings` value; no mixed A/B snapshot is observable. +5. With persisted vulgar and dominatrix intent both true but active-profile local adult + confirmation false, a real runtime threshold emits a `VERBAL_ENCOURAGEMENT` with + `vulgarMode=false` and `dominatrixMode=false`. The VBT document must still retain both intent + flags unchanged. + +Replace or refactor the test-local mirror in `VerbalEncouragementGateTest` so policy assertions +call production policy code. Keep at least one `ActiveSessionEngine` assertion proving that the +policy is used by the runtime event path. + +- [ ] **Step 3: Run the focused VBT tests and verify the master-toggle assertions fail** + +Run: + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest --tests "*Vbt*" --tests "*VerbalEncouragementGateTest*" --tests "*WorkoutCoordinatorEventTest*" --tests "*ActiveSessionEngineIntegrationTest*" --console=plain +``` + +Expected: FAIL because the coordinator has no master toggle and still emits live velocity-loss events. + +- [ ] **Step 4: Add one coherent coordinator runtime snapshot** + +```kotlin +internal data class VbtRuntimeSettings( + val enabled: Boolean = true, + val velocityLossThresholdPercent: Float = 20f, + val autoEndOnVelocityLoss: Boolean = false, +) + +class WorkoutCoordinator( + vbtEnabled: Boolean = true, + velocityLossThresholdPercent: Float = 20f, + autoEndOnVelocityLoss: Boolean = false, +) { + private val _vbtRuntimeSettings = MutableStateFlow( + VbtRuntimeSettings(vbtEnabled, velocityLossThresholdPercent, autoEndOnVelocityLoss), + ) + internal val vbtRuntimeSettings: StateFlow = + _vbtRuntimeSettings.asStateFlow() + + val biomechanicsEngine = BiomechanicsEngine(velocityLossThresholdPercent) + + internal fun updateVbtSettings( + vbtEnabled: Boolean, + thresholdPercent: Float, + autoEnd: Boolean, + ) { + require(thresholdPercent in 10f..50f) + biomechanicsEngine.updateVelocityLossThresholdPercent(thresholdPercent) + _vbtRuntimeSettings.value = VbtRuntimeSettings(vbtEnabled, thresholdPercent, autoEnd) + } +} +``` + +Do not create separate enabled/threshold/auto-end `StateFlow`s and do not add a shadow +`configuredVelocityLossThresholdPercent`; the `BiomechanicsEngine` already exposes its current +threshold for internal verification. Existing convenience getters may derive from the one snapshot, +but every live decision must capture `vbtRuntimeSettings.value` once. + +In `DefaultWorkoutSessionManager`, build the constructor snapshot and every update from +`settingsManager.userPreferences`, including `vbtEnabled`. Map all three values together and use +`distinctUntilChanged()` before calling `updateVbtSettings`. Task 5 already owns the SettingsManager +source; do not read `PreferencesManager` or add DI. + +- [ ] **Step 5: Gate only live evaluation, auto-end, and feedback** + +```kotlin +internal suspend fun evaluateLatestVbtResult() { + if (!coordinator._repCount.value.isWarmupComplete) return + val runtime = coordinator.vbtRuntimeSettings.value + if (!runtime.enabled) return + val latestResult = coordinator.biomechanicsEngine.latestRepResult.value ?: return + val velocity = latestResult.velocity + if (velocity.shouldStopSet) { + consecutiveThresholdReps++ + if (!velocityThresholdAlertEmitted) { + velocityThresholdAlertEmitted = true + coordinator._hapticEvents.emit(HapticEvent.VELOCITY_THRESHOLD_REACHED) + val prefs = settingsManager.userPreferences.value + if (prefs.beepsEnabled && prefs.verbalEncouragementEnabled) { + val effectiveVulgar = prefs.vulgarModeEnabled && prefs.adultsOnlyConfirmed + val effectiveDominatrix = effectiveVulgar && + prefs.dominatrixModeUnlocked && prefs.dominatrixModeActive + coordinator._hapticEvents.emit( + HapticEvent.VERBAL_ENCOURAGEMENT( + vulgarTier = prefs.vulgarTier, + dominatrixMode = effectiveDominatrix, + vulgarMode = effectiveVulgar, + ), + ) + } + } + if (consecutiveThresholdReps >= 2 && runtime.autoEndOnVelocityLoss) { + handleSetCompletion() + } + } else { + consecutiveThresholdReps = 0 + } +} +``` + +`processBiomechanicsForRep()` must still call `BiomechanicsEngine.processRep()` while disabled and +must call the evaluator only after capture. Do not clear baselines or rep results when toggling. Do +not gate set-summary creation, `BiomechanicsRepository.saveRepBiomechanics`, raw MCV/peak flows, +PR/progression consumers, or historical views. The master suppresses only live interpretation, +threshold feedback, verbal routing, and auto-end. + +- [ ] **Step 6: Thread VBT state through the complete active-workout UI** + +Add `val vbtEnabled: Boolean = true` to `WorkoutUiState`. In `ActiveWorkoutScreen`, include +`userPreferences.vbtEnabled` both in the `remember(...)` key list and in the constructed +`WorkoutUiState`; omitting the key leaves stale UI state after a profile switch. + +Thread `state.vbtEnabled` through both `WorkoutTab` overloads, then `WorkoutHud`, then private +`StatsPage`. Give inner composable parameters a default of `true` so previews remain source +compatible. Hide only live VBT interpretation: + +```kotlin +// WorkoutUiState +val vbtEnabled: Boolean = true, + +// ActiveWorkoutScreen -> WorkoutUiState +vbtEnabled = userPreferences.vbtEnabled, + +// WorkoutTab -> WorkoutHud -> StatsPage +vbtEnabled = state.vbtEnabled, +``` + +Keep raw MCV/peak values visible, but remove zone color/label and velocity-loss threshold/reps-left feedback while disabled: + +```kotlin +val zColor = if (vbtEnabled) { + velocityZoneColor(latestBiomechanicsResult.velocity.zone) +} else { + MaterialTheme.colorScheme.onSurface +} + +if (vbtEnabled) { + StatColumn( + label = "Zone", + value = velocityZoneLabel(latestBiomechanicsResult.velocity.zone), + color = zColor, + ) +} + +val vloss = latestBiomechanicsResult.velocity.velocityLossPercent +if (vbtEnabled && vloss != null) { + VelocityLossIndicator( + currentLossPercent = vloss, + thresholdPercent = velocityLossThresholdPercent, + shouldStopSet = latestBiomechanicsResult.velocity.shouldStopSet, + ) + latestBiomechanicsResult.velocity.estimatedRepsRemaining?.let { repsRemaining -> + StatColumn( + label = "Est. Reps Left", + value = "$repsRemaining", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} +``` + +Keep the MCV and Peak columns visible with neutral color while disabled. Hide the Zone column, +`VelocityLossIndicator`, and estimated reps-left only. Do not conditionally remove the whole +biomechanics card or `latestBiomechanicsResult` from state. + +Use `com.devil.phoenixproject.testutil.readProjectFile` for source-contract assertions covering: + +- `ActiveWorkoutScreen` remember key and `WorkoutUiState` assignment; +- outer and inner `WorkoutTab` forwarding; +- `WorkoutHud` -> `StatsPage` forwarding; +- the guarded Zone, velocity-loss, and reps-left branches; +- raw MCV and Peak remaining outside the master gate; and +- `src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/BiomechanicsHistoryCard.kt` + containing no `vbtEnabled` gate. + +- [ ] **Step 7: Pass runtime, UI-contract, and native regression tests** + +Run: + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest --tests "*Vbt*" --tests "*VerbalEncouragementGateTest*" --tests "*WorkoutCoordinatorEventTest*" --tests "*ActiveSessionEngineIntegrationTest*" --console=plain +.\gradlew.bat '-Pskip.supabase.check=true' :shared:compileKotlinIosArm64 --console=plain +``` + +Expected: PASS for disabled suppression, real metrics/history persistence, coherent profile updates, +unchanged subordinate configuration, restored behavior after re-enable, locally neutralized adult +feedback, complete Compose threading, and iOS compilation. Run `git diff --check`. No Koin graph +change is expected in this task. + +- [ ] **Step 8: Commit runtime VBT gating** + +```powershell +git add shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutCoordinator.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/DefaultWorkoutSessionManager.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutUiState.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutTab.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ActiveWorkoutScreen.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutHud.kt shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/DWSMTestHarness.kt shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/VbtEnabledRuntimeTest.kt shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/VerbalEncouragementGateTest.kt shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutCoordinatorEventTest.kt shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngineIntegrationTest.kt +git commit -m "feat: gate live VBT by active profile" +``` + +### Task 7: Harden profile deletion and journal local-key cleanup + +**Files:** +- Create: `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/ProfileDeletionMergePolicy.kt` +- Create: `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/ProfileScopedDataMerger.kt` +- Create: `shared/src/commonTest/kotlin/com/devil/phoenixproject/data/repository/ProfileDeletionMergePolicyTest.kt` +- Modify: `shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/VitruvianDatabase.sq` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/UserProfileRepository.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/di/DataModule.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/di/DomainModule.kt` +- Modify: `shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeUserProfileRepository.kt` +- Modify: `shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightUserProfileRepositoryTest.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/migration/MigrationManager.kt` +- Modify: `shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/migration/MigrationManagerTest.kt` +- Modify: `shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/migration/ProfilePreferencesMigrationTest.kt` + +**Interfaces:** +- Consumes: all direct profile-owned tables, existing profile-inclusive unique indexes, Task 3's local-safety store/journal, and `normalizeWorkoutModeKey`. +- Produces: neutral deterministic PR/badge/MVT merge models shared by deletion and `MigrationManager`, without generated/domain `PersonalRecord` type collisions. +- Produces: a schema-wide profile deletion transaction, retryable post-commit local-key cleanup, and exact active-context emission semantics. +- Adds SQLDelight queries only; no `.sqm` or schema-version bump is required. + +- [ ] **Step 1: Write failing policy, schema-coverage, transaction, and restart tests** + +Initialize every repository fixture to `ActiveProfileContext.Ready` before creating the source +profile. Use `createProfile`/`createAndActivateProfile`; do not insert an identity row behind a +still-`Switching` fake. + +Pure policy tests must cover: + +- every `MAX_WEIGHT` tie level: `weight -> oneRepMax -> achievedAt`; +- every `MAX_VOLUME` tie level: `volume -> weight -> achievedAt`; +- live rows beating tombstones and complete ties choosing the target; +- both source-winner and target-winner paths while retaining the target numeric ID; +- `uuid = winner.uuid ?: loser.uuid`, winner sync metadata, and nonblank exercise-name fallback; +- earliest badge earned time, earliest non-null celebration, and the badge metadata-donor rules; +- weighted MVT merging, including zero-count/newer-row and timestamp-tie behavior. + +Add an Android-host schema-coverage test that introspects every SQLite table/column and requires +every direct `profile_id` or `profileId` table to appear in the deletion policy. This must catch at +least `ExerciseMvt`, `RoutineGroup`, `VelocityOneRepMaxEstimate`, all `External*` profile tables, +`IntegrationStatus`, `IntegrationSyncCursor`, `UserProfilePreferences`, and +`PendingProfileLocalCleanup`. + +Repository tests must establish RED for: + +- active deletion emitting exactly `Switching(default) -> Ready(default)`; +- inactive deletion targeting the current non-default Ready profile and emitting no `Switching`; +- an injected before-commit failure emitting `Switching(target) -> prior Ready` for active delete, + while rolling back the source identity, active DB identity, preferences, every owned row, and the + cleanup journal; +- every direct profile-scoped table following the explicit policy in Step 3; +- overlapping PR/badge/MVT/external natural keys merging without unique-index failures; +- source-only external rows and retained child graphs moving, while conflicting target rows remain + byte-for-byte target-owned; +- Default remaining undeletable and target derived stats being recomputed. + +Seed meaningful, distinct local safety values, for example source +`("source-secret", calibrated=true, confirmed=true, prompted=true)` and a different target value. + +Add a true restart cleanup test: + +1. Reach Ready, create the source, and fail source local-key deletion. +2. Delete the source and assert SQL deletion committed while the source secret and journal remain. +3. Construct a fresh `MigrationManager` over the same database/settings. +4. Clear the failure and call `runRequiredMigrations()`. +5. Assert all four source safety keys and the journal are gone, target safety is unchanged, and the + required migration state is `Ready`. + +- [ ] **Step 2: Run the focused tests and observe policy/coverage/transaction failures** + +Run: + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest --tests "*ProfileDeletionMergePolicyTest*" --tests "*SqlDelightUserProfileRepositoryTest*" --tests "*MigrationManagerTest*" --tests "*ProfilePreferencesMigrationTest*" --console=plain +``` + +Expected: FAIL because current deletion omits tables, collides on unique indexes, loses sync/identity +metadata in migration merges, does not journal deletion cleanup transactionally, and does not provide +the required context/fault semantics. + +- [ ] **Step 3: Define the complete deletion matrix and neutral merge policies** + +For deleting `sourceProfileId`, define exactly: + +```kotlin +val targetProfileId = if (priorReady.profile.id == sourceProfileId) { + DEFAULT_PROFILE_ID +} else { + priorReady.profile.id +} +``` + +Require the source to exist, reject Default, and require the target to exist. + +| Tables | Deletion policy | +|---|---| +| `WorkoutSession`, `RoutineGroup`, `Routine`, `TrainingCycle`, `AssessmentResult`, `ProgressionEvent`, `StreakHistory`, `VelocityOneRepMaxEstimate` | Reassign source rows to target. | +| `PersonalRecord`, `EarnedBadge` | Resolve normalized-key collisions, retain target row IDs, then reassign remaining source rows. | +| `ExerciseMvt` | Merge same-exercise collisions, then reassign source-only rows. | +| `GamificationStats`, `RpgAttributes` | Delete source and target aggregates in-transaction; recompute target after commit. | +| `ExternalActivity`, `ExternalRoutine`, `ExternalRoutineFolder`, `ExternalProgram`, `ExternalExerciseTemplate`, `ExternalExerciseTemplateMapping`, `ExternalBodyMeasurement` | Target wins natural-key collisions byte-for-byte; remove the duplicate source row/children, then reassign source-only rows. | +| `IntegrationStatus`, `IntegrationSyncCursor` | Delete source operational state; never transplant connection state or cursors. | +| `UserProfilePreferences` | Delete source; leave target preferences unchanged. | +| `PendingProfileLocalCleanup` | Enqueue source as the first SQL mutation and retain until local Settings cleanup succeeds. | +| `UserProfile` | Delete source last. | +| Profile local safety | Delete source keys only, after SQL commit. | + +For conflicting `ExternalRoutine` and `ExternalProgram` rows, delete source routine +sets/exercises and program stats before their source parent. Source-only parents retain their IDs and +children when reassigned. + +Use neutral data classes such as `ProfileMergePersonalRecord`, `ProfileMergeEarnedBadge`, and +`ProfileMergeExerciseMvt`; do not reuse generated database rows, domain `PersonalRecord`, or +`MigrationManager`-private canonical types. + +PR key and merge rules: + +- group by `exerciseId`, `normalizeWorkoutModeKey(workoutMode)`, `prType`, and `phase`; +- collapse all raw workout-mode aliases and store the normalized mode on the retained row; +- live beats tombstone; +- `MAX_WEIGHT`: `weight -> oneRepMax -> achievedAt`; +- `MAX_VOLUME`: `volume -> weight -> achievedAt`; +- complete tie chooses target; +- retain the lowest target numeric ID, or lowest source ID when no target row exists; +- copy all winner business/sync fields, with blank winner exercise name falling back to the loser's + nonblank name; +- set `uuid = winner.uuid ?: loser.uuid`; when source wins, delete the source duplicate before + assigning its globally unique UUID to the retained target row. + +Badge merge rules: + +- retain the target numeric ID/profile; +- `earnedAt = minOf`, and `celebratedAt` is the earliest non-null celebration; +- metadata donor is live over tombstone, then earlier-earned, then target on a full tie; +- copy donor `updatedAt`, `serverId`, and `deletedAt`. + +MVT merge rules: + +- coerce counts nonnegative, sum them, and use a sample-count-weighted `personalMvtMs`; +- `updatedAt = maxOf`; +- if both counts are zero, use the newer row, target on a timestamp tie. + +- [ ] **Step 4: Add the SQLDelight adapters for every policy row** + +```sql +deletePersonalRecordById: +DELETE FROM PersonalRecord WHERE id = ?; + +updatePersonalRecordForProfileMerge: +UPDATE PersonalRecord +SET exerciseName = :exerciseName, weight = :weight, reps = :reps, oneRepMax = :oneRepMax, + achievedAt = :achievedAt, workoutMode = :workoutMode, prType = :prType, + volume = :volume, phase = :phase, updatedAt = :updatedAt, serverId = :serverId, + deletedAt = :deletedAt, cable_count = :cableCount, uuid = :uuid, + profile_id = :targetProfileId +WHERE id = :targetId; + +deleteEarnedBadgeById: +DELETE FROM EarnedBadge WHERE id = ?; + +updateEarnedBadgeForProfileMerge: +UPDATE EarnedBadge +SET earnedAt = :earnedAt, celebratedAt = :celebratedAt, updatedAt = :updatedAt, + serverId = :serverId, deletedAt = :deletedAt +WHERE id = :targetId; +``` + +Also add: + +- `reassignRoutineGroupProfile` and `reassignVelocityOneRepMaxProfile`; +- `selectAllExerciseMvtByProfile`, `deleteExerciseMvt`, an exact merged-row update/upsert, and + `reassignExerciseMvtProfile`; +- profile-wide deletes for `IntegrationStatus` and `IntegrationSyncCursor`; +- conflict-delete plus reassign queries for every external table in Step 3, including explicit + source-child deletion for conflicting routines/programs; and +- any select-by-profile query required to adapt generated rows into the neutral merge models. + +Retain existing `selectAllRecords`, `selectAllEarnedBadges`, and non-conflicting reassignment +queries. Generate the SQLDelight interfaces before Kotlin references new query names. The +`ProfileScopedDataMerger` must use these generated queries rather than delete/reinsert or raw-driver +type coupling. + +- [ ] **Step 5: Share the merger and make deletion one journaled transaction** + +Bind one `ProfileScopedDataMerger` in Koin and inject it into both +`SqlDelightUserProfileRepository` and `MigrationManager`. Refactor migration/orphan PR and badge +repair to use it; remove `CanonicalPersonalRecord`, `CanonicalEarnedBadge`, duplicate comparators, +and the delete/reinsert merge that loses numeric IDs and sync metadata. Preserve normalized +`OldSchool`/`Old School` collapse. + +Under `profileContextMutex`: + +1. Reject Default, require a `Ready` context, require source and target identities, and compute + `targetProfileId` exactly as Step 3. +2. Emit `Switching(targetProfileId)` only when the source is active. +3. In one transaction: + + - enqueue `PendingProfileLocalCleanup(sourceProfileId)` as the first mutation; + - invoke `ProfileScopedDataMerger` for PR/badge/MVT/external collisions; + - reassign or delete every table in Step 3; + - delete both source and target derived aggregates; + - delete source preferences and then the source profile; + - set active target only for active deletion; and + - invoke a constructor-injected, default-no-op `beforeProfileDeletionCommit` fault hook. + +4. On transaction failure, restore the exact prior `Ready` value and rethrow. SQL data, profile, + active identity, preferences, and the newly enqueued journal must all roll back. +5. After commit, refresh flows and publish `Ready(targetProfileId)`. Preserve the existing + reconciliation/recovery path if publication fails. +6. Only after Ready publication call `retryPendingLocalCleanup(sourceProfileId)`, which retains the + journal on non-cancellation failure. +7. Best-effort recompute target gamification/RPG through the injected `GamificationRepository`. + +The fault hook avoids brittle generated-query identifier matching and lets the test prove the +journal exists inside the transaction but disappears on rollback. + +Required context sequences: + +```text +active success: Switching(target) -> Ready(target) +inactive success: no Switching +active transaction failure: Switching(target) -> prior Ready +``` + +Update `FakeUserProfileRepository.deleteProfile` to match the target rule, active-only Switching, +preference removal, cleanup queue/retry, injected failure controls, and exact rollback/emission +semantics. Task 4's required startup migration remains the owner of draining leftover cleanup rows. + +- [ ] **Step 6: Pass policy, schema, transaction, migration, DI, and native tests** + +Run: + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:generateCommonMainVitruvianDatabaseInterface --console=plain +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest --tests "*ProfileDeletionMergePolicyTest*" --tests "*SqlDelightUserProfileRepositoryTest*" --tests "*MigrationManagerTest*" --tests "*ProfilePreferencesMigrationTest*" --tests "*KoinModuleVerifyTest*" --console=plain +.\gradlew.bat '-Pskip.supabase.check=true' :shared:compileKotlinIosArm64 --console=plain +``` + +Expected: PASS for the complete schema policy, target/source/tie/UUID/sync/celebration merge cases, +exact context emissions, rollback/journal ordering, active and inactive targets, external child graphs, +fake parity, normalized orphan migration, true restart cleanup drain, Koin construction, and iOS +compilation. Run `git diff --check` and confirm no `.sqm` was added. + +- [ ] **Step 7: Commit deletion hardening** + +```powershell +git add shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/ProfileDeletionMergePolicy.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/ProfileScopedDataMerger.kt shared/src/commonTest/kotlin/com/devil/phoenixproject/data/repository/ProfileDeletionMergePolicyTest.kt shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/VitruvianDatabase.sq shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/UserProfileRepository.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/di/DataModule.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/di/DomainModule.kt shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeUserProfileRepository.kt shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightUserProfileRepositoryTest.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/data/migration/MigrationManager.kt shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/migration/MigrationManagerTest.kt shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/migration/ProfilePreferencesMigrationTest.kt +git commit -m "fix: merge profile data safely on deletion" +``` + +### Task 8: Add profile preferences to backup schema version 5 + +**Files:** +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BackupModels.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.kt` +- Modify: `shared/src/androidMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.android.kt` +- Modify: `shared/src/iosMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.ios.kt` +- Modify: `shared/src/androidMain/kotlin/com/devil/phoenixproject/di/PlatformModule.android.kt` +- Modify: `shared/src/iosMain/kotlin/com/devil/phoenixproject/di/PlatformModule.ios.kt` +- Modify: `shared/src/commonTest/kotlin/com/devil/phoenixproject/util/BackupSerializationTest.kt` +- Modify: `shared/src/androidHostTest/kotlin/com/devil/phoenixproject/util/DataBackupManagerRoutineNameTest.kt` +- Modify: `shared/src/androidHostTest/kotlin/com/devil/phoenixproject/util/BackupJsonNavigatorTest.kt` +- Modify: `shared/src/androidHostTest/kotlin/com/devil/phoenixproject/di/KoinModuleVerifyTest.kt` only if constructor verification needs an updated extra type. + +**Interfaces:** +- Consumes: `ProfilePreferencesRepository`, `UserProfileRepository.reconcileActiveProfileContext()`, typed section values and validators from Task 3, and the profile-aware runtime boundary from Task 5. +- Produces: backup schema v5, identical buffered/streaming preference behavior, deterministic v1-v4 rack compatibility, and a reconciled active profile after every successful restore. +- Privacy boundary: profile-local voice phrase, voice calibration, adult confirmation, and adult-prompt state never enter the backup model, exporter, importer, or backup-manager constructor. + +- [ ] **Step 1: Write the complete failing compatibility, privacy, parity, and recovery matrix** + +Add focused tests with two profiles (`profile-a` and `profile-b`) and distinct values for all five sections. Exercise both `importFromJson` and the protected streaming path exposed by `StreamingImportRoundTripTest`. + +The required matrix is: + +| Backup version | `profilePreferences` | `equipmentRackItems` | Expected restore | +|---|---|---|---| +| v1-v3 | Ignore even if supplied | Ignore even if supplied | Existing profile preferences and rack remain unchanged | +| v4 | Ignore | Missing property | No rack change | +| v4 | Ignore | Present empty array | Clear rack for every eligible represented profile, or the active fallback when none is eligible | +| v4 | Ignore | Present non-empty array | Merge by item ID for every eligible represented profile, or the active fallback when none is eligible | +| v5 | Restore valid sections independently | Ignore even if supplied | Restore only entries whose profile ID is represented by backup `userProfiles` and exists after identity import | +| v6+ | Restore the known v5 fields and ignore unknown fields | Ignore | Same known-field behavior as v5, with the existing forward-compatibility warning | + +Add these assertions: + +1. A buffered v5 export contains `profilePreferences`, preserves A/B values, and does not contain `equipmentRackItems`. +2. The same export contains no sync metadata field names or values: `localGeneration`, `serverRevision`, or `dirty`. +3. Seed a distinctive safe-word phrase plus calibrated/confirmed/prompted local state. Neither buffered nor streaming output may contain the phrase or any `safeWord`, calibration, or adult-consent field, and importing a backup must leave the target's existing local-safety values unchanged. +4. Corrupt one stored section while keeping its siblings valid. Export omits that section as null/absent rather than serializing its typed fallback; valid siblings still export. +5. Import a section with a wrong JSON kind or invalid typed value. Count exactly one `entitiesWithErrors`, log/skip that section, and restore its valid siblings and normal workout entities. +6. An entry for a profile that exists in the target database but is absent from backup `userProfiles` is ignored. +7. An entry named in backup preferences but whose backup identity did not become present in SQLite is ignored. +8. Import through normal `ProfilePreferencesRepository.update*` APIs: the target section's `serverRevision` is retained, local generation advances, and dirty becomes true. +9. v4 missing, empty, explicit-null, scalar, malformed-item-array, and valid non-empty rack inputs remain distinguishable in both import paths. Missing is a no-op, empty clears, explicit null/scalar/malformed typed arrays are counted and non-destructive, and a valid non-empty merge replaces matching IDs in existing order before appending new imported IDs in backup order. +10. With usable backup profile IDs, v4 applies to those profiles and never also mutates the active fallback. With no usable represented profile, it applies only to the current active existing profile. +11. v5 ignores a supplied legacy rack even when `profilePreferences` is empty. +12. A v6 payload restores known v5 sections while unknown root, data, entry, and section fields are ignored. +13. Import the same payload into identical databases through buffered and streaming paths, then compare typed values and section metadata. +14. Extend `StreamingImportRoundTripTest` so its export/import/re-export assertions cover both profile identities and all five preference sections, not only entity counts. +15. Add an adversarial streaming document whose `data` appears before root `version`, whose `profilePreferences` appears before `userProfiles`, and whose legacy rack appears before both. The final root version must control behavior. +16. A successful import observes restored preferences from inside a recording/delegating `UserProfileRepository.reconcileActiveProfileContext()` and records one reconciliation before `Result.success`. +17. Inject an unexpected preference-repository failure after the database transaction commits. The import returns failure, reconciliation is still attempted, the restore failure stays primary, and a reconciliation failure is attached as suppressed rather than replacing it. +18. Imported `UserProfileBackup.isActive` values never switch the target app profile. Cover zero, one, and multiple backup rows marked active in both import paths; preserve the pre-import active ID when it still exists and assert exactly one SQLite identity is active afterward. +19. Cover active-identity fallback deterministically: when the captured active ID is unavailable, choose `default` if present, otherwise the first represented profile that actually exists. A streaming failure after committed identity writes must normalize by the same policy before reconciliation. +20. Inject `selectAllUserProfilesSync()` failure separately into buffered and streaming export. Both exports must fail; neither may emit a successful backup with empty `userProfiles`/`profilePreferences`, and the streaming writer must still clean up its partial file. + +Use dependencies bound to the same test database. A recording repository should delegate every operation to the real `UserProfileRepository` and intercept only `reconcileActiveProfileContext()`; do not use a disconnected fake whose profile flows cannot see imported SQL rows. + +- [ ] **Step 2: Run the backup tests and prove the v4 implementation fails the new contract** + +Run: + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest --tests "*BackupSerializationTest*" --tests "*BackupJsonNavigatorTest*" --tests "*StreamingImportRoundTripTest*" --tests "*DataBackupManager*" --console=plain +``` + +Expected: FAIL because `CURRENT_BACKUP_VERSION` is 4, the rack field cannot distinguish missing from empty in the buffered model, streaming applies rack before it knows the final version, and there is no profile-preference payload or post-import reconciliation. + +- [ ] **Step 3: Define the v5 wire model and explicit privacy boundary** + +In `BackupModels.kt`, bump the version and add independently decodable section elements: + +```kotlin +const val CURRENT_BACKUP_VERSION: Int = 5 + +@Serializable +data class ProfilePreferencesBackup( + val profileId: String, + val core: JsonElement? = null, + val rack: JsonElement? = null, + val workout: JsonElement? = null, + val led: JsonElement? = null, + val vbt: JsonElement? = null, +) +``` + +Update `BackupContent` without changing the wire name of the legacy field: + +```kotlin +@Serializable +data class BackupContent( + // Existing entity fields remain unchanged and in their current order. + val userProfiles: List = emptyList(), + val profilePreferences: List = emptyList(), + @SerialName("equipmentRackItems") + val legacyEquipmentRackItems: JsonElement? = null, + // Remaining existing entity fields remain unchanged. +) +``` + +`JsonElement` is deliberate for both preference sections and the legacy rack. A scalar, explicit null, or array containing a malformed `RackItem` must survive root decoding so the common v4 decoder can classify it as recoverable, non-destructive invalid input. A typed `List?` is forbidden because it either loses absent-versus-explicit-null presence or aborts the whole buffered backup before valid sibling data can import. + +Before decoding buffered `BackupData`, parse the root once and capture property presence independently from its raw element: + +```kotlin +val rootElement = json.parseToJsonElement(jsonString) +val rootObject = rootElement.jsonObject +val dataObject = rootObject["data"]?.jsonObject + ?: throw IllegalArgumentException("Backup data object is missing or invalid") +val legacyRackFieldPresent = dataObject.containsKey("equipmentRackItems") +val legacyRackElement = dataObject["equipmentRackItems"] +val backup = json.decodeFromJsonElement(rootElement) +``` + +This preserves all five states: absent (`containsKey == false`), valid empty array, explicit `JsonNull`, scalar/wrong JSON kind, and an array whose members fail typed `RackItem` decoding. Only the valid empty array is a destructive clear. + +Configure `BaseDataBackupManager.json` as: + +```kotlin +protected val json = Json { + prettyPrint = false + ignoreUnknownKeys = true + encodeDefaults = true + explicitNulls = false +} +``` + +Update the backup schema history and `BackupPrivacyMetadata.userFacingSummary` to state that full personal-data exports include profile training preferences but exclude auth/runtime secrets, section sync bookkeeping, voice phrase, voice calibration, and adult consent/prompt state. + +Do not add `ProfileLocalSafetyPreferences`, `ProfileLocalSafetyStore`, section metadata, `schemaVersion`, or `legacyMigrationVersion` to any backup wire type. + +- [ ] **Step 4: Replace the obsolete equipment-rack dependency with the two profile repositories** + +After v5 export and v4 restore use the preference aggregate directly, `BaseDataBackupManager` no longer needs `EquipmentRackRepository`. Replace its constructor with: + +```kotlin +abstract class BaseDataBackupManager( + private val database: VitruvianDatabase, + private val profilePreferencesRepository: ProfilePreferencesRepository, + private val userProfileRepository: UserProfileRepository, +) : DataBackupManager +``` + +Thread both dependencies through the platform classes: + +```kotlin +class AndroidDataBackupManager( + private val context: Context, + database: VitruvianDatabase, + private val preferencesManager: PreferencesManager, + private val destinationResolver: BackupDestinationResolver, + profilePreferencesRepository: ProfilePreferencesRepository, + userProfileRepository: UserProfileRepository, +) : BaseDataBackupManager( + database, + profilePreferencesRepository, + userProfileRepository, +) +``` + +```kotlin +class IosDataBackupManager( + database: VitruvianDatabase, + private val preferencesManager: PreferencesManager, + private val destinationResolver: BackupDestinationResolver, + profilePreferencesRepository: ProfilePreferencesRepository, + userProfileRepository: UserProfileRepository, +) : BaseDataBackupManager( + database, + profilePreferencesRepository, + userProfileRepository, +) +``` + +Update the real bindings: + +```kotlin +single { + AndroidDataBackupManager(androidContext(), get(), get(), get(), get(), get()) +} +``` + +```kotlin +single { + IosDataBackupManager(get(), get(), get(), get(), get()) +} +``` + +Update both Android-host `TestDataBackupManager` subclasses in `DataBackupManagerRoutineNameTest.kt` and `BackupJsonNavigatorTest.kt`. Build a real `ProfilePreferencesRepository` and a real or recording/delegating `UserProfileRepository` against the same `VitruvianDatabase`. Do not pass `SettingsEquipmentRackRepository` or inject/read `ProfileLocalSafetyStore` from backup code. + +- [ ] **Step 5: Export the same typed sections in buffered and streaming paths** + +Share one validity-aware conversion: + +```kotlin +private inline fun Json.encodeValidBackupSection( + section: ProfilePreferenceSection, +): JsonElement? = if (section.validity is ProfilePreferenceValidity.Valid) { + encodeToJsonElement(section.value) +} else { + null +} + +private fun UserProfilePreferences.toBackup(json: Json) = ProfilePreferencesBackup( + profileId = profileId, + core = json.encodeValidBackupSection(core), + rack = json.encodeValidBackupSection(rack), + workout = json.encodeValidBackupSection(workout), + led = json.encodeValidBackupSection(led), + vbt = json.encodeValidBackupSection(vbt), +) +``` + +In `exportAllData`, identity loading is a required root operation: + +```kotlin +val userProfiles = queries.selectAllUserProfilesSync().executeAsList() +``` + +Do not retain the current `runCatching { selectAllUserProfilesSync() }.getOrElse { emptyList() }` fallback. A failed identity query must fail the buffered export before preferences are assembled. Then load `profilePreferencesRepository.get(profile.id)` for every exported `UserProfile` and populate `BackupContent.profilePreferences`. Do not synthesize typed defaults if the preference row itself is unexpectedly missing; fail the export rather than silently claiming a complete backup. + +In `streamExportToWriter`, execute the same direct, fatal `selectAllUserProfilesSync()` query before deriving profile preferences or writing a successful identity/preferences payload. Do not catch it to an empty list. Load the same small per-profile preference list and serialize it with `ProfilePreferencesBackup.serializer()`. Emit `userProfiles` and `profilePreferences` and explicitly omit `equipmentRackItems`. Both export paths must use `toBackup`; neither may inspect raw Settings keys or `ProfileLocalSafetyStore`. The existing outer streaming-export failure path remains responsible for closing and deleting the partial writer. + +Invalid/unsupported stored sections serialize as absent/null while valid siblings remain present. Because `explicitNulls = false`, the buffered v5 encoder also omits the nullable legacy rack field. + +- [ ] **Step 6: Import buffered profile identities first and seed preference rows atomically** + +In `importFromJson`, keep duplicate-detection snapshots outside the transaction, but move the entire user-profile import block to the first operation inside the database transaction, before sessions, routine groups, routines, PRs, cycles, badges, or any other profile-linked entity. Capture the target app's active identity before any import write, using the existing deterministic profile order when repairing a legacy multiple-active state: + +```kotlin +val preImportActiveProfileId = queries.getAllProfiles() + .executeAsList() + .firstOrNull { it.isActive == 1L } + ?.id +``` + +Collect distinct decoded backup identity IDs in wire order: + +```kotlin +val representedProfileIds = backup.data.userProfiles + .map(UserProfileBackup::id) + .toCollection(linkedSetOf()) +``` + +Backup `isActive` is informational only and must never switch the target app. During entry handling, insert every new identity inactive, leave every existing identity's active flag untouched, and seed the aggregate for both: + +```kotlin +backup.data.userProfiles.distinctBy(UserProfileBackup::id).forEach { profile -> + if (profile.id !in existingUserProfileIds) { + queries.insertUserProfileIgnore( + id = profile.id, + name = profile.name, + colorIndex = profile.colorIndex.toLong(), + createdAt = profile.createdAt, + isActive = 0L, + ) + userProfilesImported++ + } else { + userProfilesSkipped++ + } + queries.insertDefaultProfilePreferences(profile.id, 1L) +} +normalizeImportedActiveIdentity(preImportActiveProfileId, representedProfileIds) +``` + +Do not catch and continue an infrastructure failure between identity insertion and `insertDefaultProfilePreferences`; those two writes must remain an invariant of the outer transaction. Remove the old late user-profile block. The subsequent deferred helper re-queries SQLite and intersects represented IDs with rows that actually exist, so an orphan preference entry can never create an identity. + +Use one identity normalizer from both import paths and post-commit recovery: + +```kotlin +private fun normalizeImportedActiveIdentity( + preImportActiveProfileId: String?, + representedProfileIds: Set, +): String { + val existingProfileIds = queries.selectAllUserProfileIds() + .executeAsList() + .toSet() + val activeProfileId = preImportActiveProfileId + ?.takeIf { it in existingProfileIds } + ?: "default".takeIf { it in existingProfileIds } + ?: representedProfileIds.firstOrNull { it in existingProfileIds } + ?: error("No usable profile identity after backup import") + + queries.setActiveProfile(activeProfileId) + return activeProfileId +} +``` + +Call it inside the same outer buffered transaction immediately after identity insertion/seeding. `setActiveProfile` is the one atomic normalization write: it clears every other active flag and selects exactly one preserved/fallback identity. The selection order is fixed: captured pre-import active when still present, otherwise Default, otherwise the first usable represented identity. Never consult imported `isActive` flags. + +Declare `var activeIdentityNormalized = false` beside the buffered import's `databaseWorkCommitted`/`reconciliationAttempted` state. Set both `databaseWorkCommitted = true` and `activeIdentityNormalized = true` only after the outer buffered transaction containing identity insertion, seeding, normalization, and entity writes returns successfully. + +- [ ] **Step 7: Implement one common deferred restore helper for both import paths** + +Define a common payload: + +```kotlin +private data class DeferredProfileRestore( + val backupVersion: Int, + val profilePreferences: List, + val legacyRackFieldPresent: Boolean, + val legacyRackElement: JsonElement?, + val representedProfileIds: Set, +) +``` + +At restore time, never trust the payload IDs alone: + +```kotlin +val profileIdsAfterImport = queries.selectAllUserProfileIds() + .executeAsList() + .toSet() +val eligibleProfileIds = deferred.representedProfileIds + .filterTo(linkedSetOf()) { it in profileIdsAfterImport } +``` + +Decode and validate without enclosing the repository update in the decode catch: + +```kotlin +private inline fun decodeBackupSection( + profileId: String, + sectionName: String, + element: JsonElement, + validate: (T) -> List, + onInvalid: (String, String, Throwable?) -> Unit, +): T? = runCatching { + val value = json.decodeFromJsonElement(element.jsonObject) + val validationErrors = validate(value) + require(validationErrors.isEmpty()) { validationErrors.joinToString(",") } + value +}.fold( + onSuccess = { it }, + onFailure = { + onInvalid(profileId, sectionName, it) + null + }, +) +``` + +For each skipped section, increment `entitiesWithErrors` once and log the profile ID and section name. Decode/validation failures are recoverable. Unexpected failures from `profilePreferencesRepository.get` or `update*` are storage failures and must propagate to the import result rather than being mislabeled as malformed backup data. + +Decode the v4 rack independently from root and preference-section decoding: + +```kotlin +private fun decodeLegacyV4Rack( + element: JsonElement?, + onInvalid: (String, String, Throwable?) -> Unit, +): List? { + if (element == null) { + onInvalid("backup", "equipmentRackItems", null) + return null + } + return runCatching { + json.decodeFromJsonElement>(element) + }.fold( + onSuccess = { it }, + onFailure = { + onInvalid("backup", "equipmentRackItems", it) + null + }, + ) +} +``` + +This function is called only when the property-presence bit is true and the final version is exactly 4. `JsonNull`, a scalar/object, and an array with any malformed typed member all return null after one recoverable error and make no rack write. A successfully decoded empty list remains distinguishable and clears. + +Implement the exact version behavior: + +```kotlin +when { + deferred.backupVersion < 4 -> Unit + + deferred.backupVersion == 4 && !deferred.legacyRackFieldPresent -> Unit + + deferred.backupVersion == 4 -> restoreLegacyV4Rack( + items = decodeLegacyV4Rack(deferred.legacyRackElement, onInvalid), + eligibleProfileIds = eligibleProfileIds, + profileIdsAfterImport = profileIdsAfterImport, + now = now, + onInvalid = onInvalid, + ) + + deferred.backupVersion >= 5 -> restoreV5ProfilePreferences( + entries = deferred.profilePreferences, + eligibleProfileIds = eligibleProfileIds, + now = now, + onInvalid = onInvalid, + ) +} +``` + +`restoreLegacyV4Rack` returns without mutation when the decoded `items` argument is null. For a valid array, choose targets exactly once: + +```kotlin +val targetProfileIds = eligibleProfileIds.ifEmpty { + listOfNotNull( + queries.getActiveProfile() + .executeAsOneOrNull() + ?.id + ?.takeIf { it in profileIdsAfterImport }, + ) +} +``` + +Do not add the active profile when eligible represented targets exist. A present empty list writes `RackPreferences(items = emptyList())` to every target. A non-empty list filters blank IDs, resolves duplicate imported IDs deterministically, replaces matching existing items without reordering existing rows, then appends genuinely new imported items in backup order. Validate the final `RackPreferences` before `updateRack`; a malformed candidate is counted and skipped for that target. + +For v5 and newer, ignore the legacy rack field unconditionally. Process only entries whose `profileId` is in `eligibleProfileIds`, decode every non-null section independently with the real `ProfilePreferencesValidator`, and call: + +```kotlin +profilePreferencesRepository.updateCore(profileId, value, now) +profilePreferencesRepository.updateRack(profileId, value, now) +profilePreferencesRepository.updateWorkout(profileId, value, now) +profilePreferencesRepository.updateLed(profileId, value, now) +profilePreferencesRepository.updateVbt(profileId, value, now) +``` + +These existing SQLDelight updates intentionally preserve each target server revision, increment local generation, set dirty, and stamp the local import time. Do not write raw preference SQL or restore backup sync metadata. + +- [ ] **Step 8: Make streaming import defer all order-dependent profile state until root end** + +The root JSON object is unordered. `importFromStream` must not decide v4/v5 behavior when it first sees a data field. Track these values for the entire parse: + +```kotlin +val preImportActiveProfileId = queries.getAllProfiles() + .executeAsList() + .firstOrNull { it.isActive == 1L } + ?.id +var backupVersion = 1 +val deferredPreferenceEntries = mutableListOf() +var legacyRackFieldPresent = false +var legacyRackElement: JsonElement? = null +val representedProfileIds = linkedSetOf() +var databaseWorkCommitted = false +var activeIdentityNormalized = false +var reconciliationAttempted = false +``` + +Change the data-field handlers: + +1. `equipmentRackItems`: set `legacyRackFieldPresent = true`, capture the complete value with `nav.nextValueAsString()`, and parse only that raw value to `JsonElement`. Do not decode it as `List` and do not call the old `importEquipmentRackItems`. This preserves `JsonNull`, scalar/object, empty array, and malformed typed members until the common root-end v4 decoder. +2. `profilePreferences`: stream the outer array one entry at a time, decode each `ProfilePreferencesBackup`, count malformed entry objects, and retain the successfully decoded entries. Do not restore them yet. +3. `userProfiles`: keep streaming identities in their own database transaction. Insert new identities with `isActive = 0L`, never update an existing identity's active flag from backup input, seed `queries.insertDefaultProfilePreferences(profile.id, 1L)` in that same transaction for both new and existing identities, and add successfully decoded IDs to `representedProfileIds` in wire order. +4. Root `version`: update `backupVersion` wherever it appears; no prior data handler may have consumed version-sensitive state. + +Set `databaseWorkCommitted = true` immediately after each existing streaming entity-type transaction returns. This matters because streaming import is intentionally multi-transaction and a malformed later field can fail after earlier rows committed. + +Only after `nav.endObject()` for the root: + +1. In one database transaction, call `normalizeImportedActiveIdentity(preImportActiveProfileId, representedProfileIds)`. Only after that transaction returns, set `databaseWorkCommitted = true` and `activeIdentityNormalized = true`. This is the only operation that applies active flags and guarantees exactly one preserved/fallback identity. +2. Build `DeferredProfileRestore` using the final root version, preference entries, rack presence/raw `JsonElement`, and buffered represented profile IDs. Do not predecode the rack here; the common helper owns typed v4 decoding. +3. Invoke the same deferred restore/reconciliation completion used by buffered import. + +The adversarial-order test must prove: + +```text +data/profilePreferences +→ data/equipmentRackItems +→ data/userProfiles +→ root version +→ root end +→ normalize exactly one target active identity +→ one version-correct deferred restore +``` + +Do not buffer sessions, metrics, or other potentially large tables; only the small profile preference/rack payload and identity-ID set are deferred. + +- [ ] **Step 9: Reconcile after restore and on every post-commit failure** + +Successful buffered and streaming imports must have this exact order: + +```text +database identity/entity writes +→ normalize exactly one active identity from pre-import state +→ deferred profile-preference or v4 rack restore +→ userProfileRepository.reconcileActiveProfileContext() +→ Result.success +``` + +Wrap deferred restore so reconciliation is still attempted when preference storage fails after the database commit: + +```kotlin +private suspend fun restoreDeferredAndReconcile( + deferred: DeferredProfileRestore, + now: Long, + onInvalid: (String, String, Throwable?) -> Unit, +) { + try { + restoreDeferredProfileState(deferred, now, onInvalid) + } catch (restoreFailure: Throwable) { + val reconcileFailure = runCatching { + withContext(NonCancellable) { + userProfileRepository.reconcileActiveProfileContext() + } + }.exceptionOrNull() + reconcileFailure?.let(restoreFailure::addSuppressed) + throw restoreFailure + } + + userProfileRepository.reconcileActiveProfileContext() +} +``` + +Set `reconciliationAttempted = true` immediately before calling this wrapper because it guarantees an attempt on both its success and restore-failure paths. If the normal reconciliation call itself fails, the import fails. + +In each outer catch, when `databaseWorkCommitted` is true and `reconciliationAttempted` is false, first reapply the same active-identity normalization and then make one best-effort reconciliation. Attempt reconciliation even if normalization itself fails: + +```kotlin +withContext(NonCancellable) { + val normalizationFailure = if (activeIdentityNormalized) { + null + } else { + runCatching { + database.transaction { + normalizeImportedActiveIdentity( + preImportActiveProfileId, + representedProfileIds, + ) + } + activeIdentityNormalized = true + }.exceptionOrNull() + } + normalizationFailure?.let(importFailure::addSuppressed) + + val reconcileFailure = runCatching { + userProfileRepository.reconcileActiveProfileContext() + }.exceptionOrNull() + reconcileFailure?.let(importFailure::addSuppressed) +} +``` + +Keep the original import/parse failure primary. Do not normalize/reconcile a buffered transaction that rolled back before commit. Buffered import sets `activeIdentityNormalized` only after its all-entity transaction commits; streaming sets it only after the root-end normalization transaction commits. A streaming failure after earlier entity work but before root-end normalization therefore runs normalization before reconciliation, while a later failure does not perform a redundant active write. Imported backup `isActive` flags are never consulted on either the success or recovery path. + +Construct and return `Result.success(ImportResult(...))` only after the wrapper completes. Reconciliation may read local safety while rebuilding `Ready`, but backup code must never serialize, overwrite, clear, or derive local-safety values. + +- [ ] **Step 10: Pass focused backup, parity, DI, and platform compilation checks** + +Run: + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest --tests "*BackupSerializationTest*" --tests "*BackupJsonNavigatorTest*" --tests "*StreamingImportRoundTripTest*" --tests "*DataBackupManager*" --tests "*KoinModuleVerifyTest*" --console=plain +``` + +Expected: PASS for the full v1-v6 matrix, A/B section round trip, local-only privacy, metadata semantics, malformed-section isolation, identity allow-listing, buffered/streaming parity, adversarial field ordering, and reconciliation recovery behavior. + +Then run: + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:compileKotlinAndroid :shared:compileKotlinIosSimulatorArm64 --console=plain +``` + +Expected: PASS with the updated Android/iOS constructors and platform bindings. + +- [ ] **Step 11: Commit backup v5** + +```powershell +git add shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BackupModels.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.kt shared/src/androidMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.android.kt shared/src/iosMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.ios.kt shared/src/androidMain/kotlin/com/devil/phoenixproject/di/PlatformModule.android.kt shared/src/iosMain/kotlin/com/devil/phoenixproject/di/PlatformModule.ios.kt shared/src/commonTest/kotlin/com/devil/phoenixproject/util/BackupSerializationTest.kt shared/src/androidHostTest/kotlin/com/devil/phoenixproject/util/DataBackupManagerRoutineNameTest.kt shared/src/androidHostTest/kotlin/com/devil/phoenixproject/util/BackupJsonNavigatorTest.kt shared/src/androidHostTest/kotlin/com/devil/phoenixproject/di/KoinModuleVerifyTest.kt +git commit -m "feat: back up profile preferences in schema v5" +``` + +### Task 9: Wire dependency injection and verify the data foundation + +**Files:** +- Verify first; modify only a genuine omission reported by verification: + - `shared/src/commonMain/kotlin/com/devil/phoenixproject/di/DataModule.kt` + - `shared/src/commonMain/kotlin/com/devil/phoenixproject/di/DomainModule.kt` + - `shared/src/commonMain/kotlin/com/devil/phoenixproject/di/SyncModule.kt` + - `shared/src/commonMain/kotlin/com/devil/phoenixproject/di/KoinInit.kt` + - `shared/src/androidHostTest/kotlin/com/devil/phoenixproject/di/KoinModuleVerifyTest.kt` + - `shared/src/androidMain/kotlin/com/devil/phoenixproject/di/PlatformModule.android.kt` + - `shared/src/iosMain/kotlin/com/devil/phoenixproject/di/PlatformModule.ios.kt` + - any concrete MainViewModel/application host file named by a compiler or Koin failure. + +**Interfaces:** +- Consumes: every implementation in this plan. +- Produces: a statically verified common/Android-host Koin graph, successful iOS DI compilation, and the stable foundation required by both later plans. +- Expected scope: Tasks 3-8 already own their bindings and constructor changes, so this task is verification-first and should normally make no production-code change or commit. + +- [ ] **Step 1: Verify the existing static Koin graph before changing it** + +```kotlin +private val platformProvidedTypes = listOf( + DriverFactory::class, + Settings::class, + BleRepository::class, + CsvExporter::class, + CsvImporter::class, + DataBackupManager::class, + ConnectivityChecker::class, + SupabaseConfig::class, + SafeWordListenerFactory::class, + HealthIntegration::class, + HealthWorkoutWriter::class, + WorkoutServiceController::class, + Function0::class, + Function2::class, + Function3::class, + Function4::class, + Function5::class, +) + +@Test +fun verifyAppModule() { + appModule.verify(extraTypes = platformProvidedTypes) +} +``` + +Do not invent a runtime `startTestKoin().use` fixture: the project has no such helper, and its platform graph needs concrete Android dependencies. The JVM `verify` test cross-checks every constructor definition in `appModule`; Task 5's focused behavior tests establish the concrete gate/rack/health/safe-word implementations, and Step 5 separately compiles the iOS module. + +- [ ] **Step 2: Run Koin verification** + +Run: + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest --tests "*KoinModuleVerifyTest*" --console=plain +``` + +Expected: PASS. If it fails, a Task 3-8 binding or required `extraTypes` entry was missed; fix that omission before continuing rather than documenting an expected partial graph. + +- [ ] **Step 3: Repair only a verified graph omission** + +If Step 2 passes, make no graph edit. Retain and verify Task 3's focused-store/expanded `UserProfileRepository` registrations, Task 4's legacy-reader/expanded `MigrationManager`, Task 5's `RequiredMigrationGate`, profile rack, health, and safe-word registrations, Task 7's shared merger, and Task 8's Android/iOS backup-manager constructor bindings; do not add duplicate definitions. `SettingsManager` remains owned by `MainViewModel` because it requires `viewModelScope`; do not register it as an application singleton. + +If verification or platform compilation fails, repair only the concrete missing definition, `extraTypes` entry, constructor argument, or platform host binding named by that failure and rerun the failing command immediately. Avoid a dependency cycle: `UserProfileRepository` may depend on focused stores, gamification, and the merger, but neither focused store nor the merger may depend on `UserProfileRepository`. + +- [ ] **Step 4: Run focused and full shared verification** + +Run: + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:generateCommonMainVitruvianDatabaseInterface :shared:verifyCommonMainVitruvianDatabaseMigration :shared:validateSchemaManifest --console=plain +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest --tests "*ProfilePreferences*" --tests "*SqlDelightUserProfileRepositoryTest*" --tests "*SettingsManagerTest*" --tests "*VbtEnabledRuntimeTest*" --tests "*Backup*" --tests "*KoinModuleVerifyTest*" --console=plain +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest --continue --console=plain +``` + +Expected: every command exits 0. The full shared suite reports no profile leakage or migration parity regression. + +- [ ] **Step 5: Compile both platforms and assemble Android** + +Run: + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:compileKotlinIosArm64 :androidApp:assembleDebug --console=plain +``` + +Expected: BUILD SUCCESSFUL with no missing platform constructor or serializer. + +- [ ] **Step 6: Audit ownership and privacy mechanically** + +Run: + +```powershell +$preferencesFile = 'shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/PreferencesManager.kt' +$productionRoots = @( + 'shared/src/commonMain', + 'shared/src/androidMain', + 'shared/src/iosMain' +) + +# Derive the complete legacy-writer surface from the concrete Settings manager. +$legacySetterNames = @( + rg -o --replace '$1' ` + 'internal\s+(?:suspend\s+)?fun\s+(set[A-Za-z0-9_]+)\s*\(' ` + $preferencesFile +) +if ($LASTEXITCODE -ne 0 -or $legacySetterNames.Count -eq 0) { + throw 'Could not derive internal legacy preference setters' +} +$setterAlternation = ($legacySetterNames | ForEach-Object { + [Regex]::Escape($_) +}) -join '|' + +# The only legitimate same-name calls are these reviewed file/receiver/type pairs. +$allowedCallContexts = @( + [pscustomobject]@{ + File = 'shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/MainViewModel.kt' + Receiver = 'settingsManager' + TypeProof = '\bval\s+settingsManager\s*=\s*SettingsManager\s*\(' + }, + [pscustomobject]@{ + File = 'shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/navigation/NavGraph.kt' + Receiver = 'viewModel' + TypeProof = '\bviewModel\s*:\s*MainViewModel\b' + }, + [pscustomobject]@{ + File = 'shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/DefaultWorkoutSessionManager.kt' + Receiver = 'settingsManager' + TypeProof = '\bprivate\s+val\s+settingsManager\s*:\s*SettingsManager\b' + }, + [pscustomobject]@{ + File = 'shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/BleConnectionManager.kt' + Receiver = 'bleRepository' + TypeProof = '\bprivate\s+val\s+bleRepository\s*:\s*BleRepository\b' + } +) +$allowedFileReceiverPairs = @{} +foreach ($context in $allowedCallContexts) { + rg -n --pcre2 $context.TypeProof $context.File | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "Missing reviewed type proof for $($context.File)|$($context.Receiver)" + } + $allowedFileReceiverPairs["$($context.File)|$($context.Receiver)"] = $true +} + +# Search every production source set, normalize each path, extract every receiver +# on each matching line, and reject anything outside the proven allowlist. +$callPattern = '\b(?[A-Za-z_][A-Za-z0-9_]*)\s*\.\s*(?:' + + $setterAlternation + ')\s*\(' +$callCandidates = @( + rg -n --pcre2 --glob '*.kt' --glob '!PreferencesManager.kt' ` + $callPattern $productionRoots +) +if ($LASTEXITCODE -gt 1) { + throw "Legacy-setter call search failed with exit code $LASTEXITCODE" +} +$unexpectedLegacyCalls = [System.Collections.Generic.List[string]]::new() +foreach ($candidate in $callCandidates) { + $parsed = [Regex]::Match( + $candidate, + '^(?.*?):(?[0-9]+):(?.*)$' + ) + if (-not $parsed.Success) { + throw "Could not parse legacy-setter candidate: $candidate" + } + $normalizedFile = $parsed.Groups['file'].Value -replace '\\', '/' + $receiverMatches = [Regex]::Matches( + $parsed.Groups['source'].Value, + $callPattern + ) + foreach ($receiverMatch in $receiverMatches) { + $receiver = $receiverMatch.Groups['receiver'].Value + $pair = "$normalizedFile|$receiver" + if (-not $allowedFileReceiverPairs.ContainsKey($pair)) { + $unexpectedLegacyCalls.Add("$pair :: $candidate") + } + } +} +if ($unexpectedLegacyCalls.Count -gt 0) { + $unexpectedLegacyCalls | Write-Host + throw 'Production code still calls an internal legacy Settings preference writer' +} + +# Build the forbidden wire-name set in camelCase and snake_case. Include local +# safety/calibration/consent aggregates plus unprefixed and per-section sync metadata. +$wireNames = [System.Collections.Generic.List[string]]::new() +@( + 'localSafety', 'local_safety', + 'safeWord', 'safe_word', + 'safeWordCalibrated', 'safe_word_calibrated', + 'safeWordCalibration', 'safe_word_calibration', + 'adultsOnlyConfirmed', 'adults_only_confirmed', + 'adultsOnlyPrompted', 'adults_only_prompted', + 'adultsOnlyConsent', 'adults_only_consent', + 'adultConsent', 'adult_consent', + 'localGeneration', 'local_generation', + 'serverRevision', 'server_revision', + 'dirty' +) | ForEach-Object { $wireNames.Add($_) } +foreach ($section in @('core', 'rack', 'workout', 'led', 'vbt')) { + $wireNames.Add("${section}UpdatedAt") + $wireNames.Add("${section}_updated_at") + $wireNames.Add("${section}LocalGeneration") + $wireNames.Add("${section}_local_generation") + $wireNames.Add("${section}ServerRevision") + $wireNames.Add("${section}_server_revision") + $wireNames.Add("${section}Dirty") + $wireNames.Add("${section}_dirty") +} +# Do not deny unprefixed updatedAt: ordinary backup entities legitimately use it. +# ProfilePreferencesBackup has only typed section properties, so preference metadata +# cannot expose an unprefixed updatedAt; section-prefixed forms are denied above. +$wireAlternation = ( + $wireNames | + Sort-Object -Unique | + ForEach-Object { [Regex]::Escape($_) } +) -join '|' + +# Match only Kotlin declarations, property access/named assignment, or an exact +# serialized key. Prose such as the privacy summary remains allowed. +$wirePattern = '\b(?:val|var)\s+(?:' + $wireAlternation + ')\b' + + '|\.(?:' + $wireAlternation + ')\b' + + '|\b(?:' + $wireAlternation + ')\s*=' + + '|["''](?:' + $wireAlternation + ')["'']' +$wireLeaks = @( + rg -n --pcre2 $wirePattern ` + shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BackupModels.kt ` + shared/src/commonMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.kt +) +if ($LASTEXITCODE -gt 1) { + throw "Backup privacy search failed with exit code $LASTEXITCODE" +} +if ($wireLeaks.Count -gt 0) { + $wireLeaks | Write-Host + throw 'Local safety/consent or sync metadata entered the backup wire path' +} + +git diff --check +``` + +Expected: all internal legacy setter names are derived; every same-name production call maps to one of the four reviewed file/receiver pairs whose `SettingsManager`, `MainViewModel`, or `BleRepository` type proof is present; every other pair fails. No local-safety/consent or unprefixed/section-prefixed sync-metadata property, access, named assignment, or exact camelCase/snake_case serialized key appears in the backup wire path. `git diff --check` prints nothing, and privacy-summary prose is intentionally not matched. + +- [ ] **Step 7: Conditionally commit genuine wiring repairs** + +```powershell +$stagedState = git diff --cached --quiet +if ($LASTEXITCODE -eq 1) { + throw 'Index already contains staged changes; review them before Task 9' +} +if ($LASTEXITCODE -gt 1) { + throw 'Could not inspect the Git index' +} + +$reviewableWiringPaths = @( + 'shared/src/commonMain/kotlin/com/devil/phoenixproject/di/DataModule.kt', + 'shared/src/commonMain/kotlin/com/devil/phoenixproject/di/DomainModule.kt', + 'shared/src/commonMain/kotlin/com/devil/phoenixproject/di/SyncModule.kt', + 'shared/src/commonMain/kotlin/com/devil/phoenixproject/di/KoinInit.kt', + 'shared/src/androidHostTest/kotlin/com/devil/phoenixproject/di/KoinModuleVerifyTest.kt', + 'shared/src/androidMain/kotlin/com/devil/phoenixproject/di/PlatformModule.android.kt', + 'shared/src/iosMain/kotlin/com/devil/phoenixproject/di/PlatformModule.ios.kt', + 'shared/src/androidMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.android.kt', + 'shared/src/iosMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.ios.kt', + 'shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/MainViewModel.kt', + 'androidApp/src/main/kotlin/com/devil/phoenixproject/VitruvianApp.kt' +) +$reviewedWiringFiles = @( + git diff --name-only --diff-filter=AM -- $reviewableWiringPaths +) + +if ($reviewedWiringFiles.Count -eq 0) { + Write-Host 'Task 9 verification passed with no wiring changes; skip the commit.' +} else { + $reviewedWiringFiles | ForEach-Object { + Write-Host "Reviewing Task 9 wiring change: $_" + } + git add -- $reviewedWiringFiles + git diff --cached --check + if ($LASTEXITCODE -ne 0) { + throw 'Staged Task 9 wiring diff failed whitespace validation' + } + git diff --cached --quiet + if ($LASTEXITCODE -eq 0) { + Write-Host 'No effective staged wiring change; skip the empty commit.' + } elseif ($LASTEXITCODE -eq 1) { + git commit -m "chore: wire profile preference foundation" + } else { + throw 'Could not inspect the staged Task 9 wiring diff' + } +} +``` + +Expected: normally no files changed and no Task 9 commit is created. If a verification command required a reviewed repair, stage exactly the changed common/platform/host files that implement that repair, rerun the affected verification, and create the commit only when the staged diff is non-empty. Add a compiler-named wiring file to `$reviewableWiringPaths` only after reviewing it; never stage unrelated plan or feature work. + +## Completion Gate + +- Schema/migration/reconciliation all report version 43. +- Every existing profile has `legacy_migration_version = 1` after the awaited migration; a new profile starts with defaults and `vbtEnabled = true`. +- A/B tests prove every profile-owned consumer follows the active context without restart. +- A migration failure cannot expose root navigation; Retry succeeds without overwriting completed rows. +- Profile deletion handles overlapping PR/badge keys and eventually deletes local safety keys. +- Backup v5 round-trips every syncable section while excluding safety and sync metadata. +- The working tree contains only reviewed changes for this plan before either dependent plan begins. diff --git a/docs/superpowers/plans/2026-07-11-profile-preferences-sync-backend.md b/docs/superpowers/plans/2026-07-11-profile-preferences-sync-backend.md new file mode 100644 index 000000000..3be284426 --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-profile-preferences-sync-backend.md @@ -0,0 +1,8876 @@ +# Profile Preferences Sync and Backend Handoff Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Synchronize the five approved profile-preference sections through the existing Supabase push/pull endpoint with revision-based conflict resolution, while delivering an executable, security-hardened backend handoff from the mobile repository. + +**Architecture:** SQLDelight remains the local source of truth. Mobile takes valid dirty section snapshots, maps them to additive wire DTOs, sends metadata first and then preference-only payloads, and applies canonical acknowledgements with an in-flight local-generation ledger. Supabase stores one `local_profile_preferences` row per existing remote `local_profiles` parent, and an Edge-only, service-role-backed atomic mutation RPC owns server revisions and timestamps. + +**Tech Stack:** Kotlin Multiplatform, kotlinx.serialization `JsonObject`, Coroutines/Flow, SQLDelight 2.2.1, Ktor, multiplatform-settings, Supabase Postgres/RLS, Supabase Edge Functions (Deno/TypeScript), Kotlin Test. + +## Global Constraints + +- Implement only the approved design in `docs/superpowers/specs/2026-07-11-profile-tab-preferences-design.md`. +- This plan depends on `docs/superpowers/plans/2026-07-11-profile-preferences-data-foundation.md`; complete its schema-43, typed-document, repository, required-migration-gate, and profile-local safety tasks before Task 3 here. +- The five syncable section names are exactly `CORE`, `RACK`, `WORKOUT`, `LED`, and `VBT`. +- `safeWord`, `safeWordCalibrated`, `adultsOnlyConfirmed`, and `adultsOnlyPrompted` are profile-prefixed local Settings values. They never enter sync DTOs, logs, backend SQL payloads, or backup payloads. +- `voiceStopEnabled` is syncable intent. Effective voice stop still requires a locally configured and calibrated phrase. +- Local wall-clock timestamps are audit metadata only. Cross-device ordering uses per-section server revisions exclusively. +- `localGeneration` is device-local, monotonic, and never serialized. +- The current Kotlin push request type is `PortalSyncPayload`; do not introduce a duplicate `PortalSyncPushRequest` type. +- All new request and response fields are additive and have backward-compatible constructor defaults. +- A single preference mutation's exact raw UTF-8 JSON element span is at most 262,144 bytes. Whenever `profilePreferenceSections` is present, the complete original HTTP `PortalSyncPayload` byte sequence is at most 524,288 bytes. Edge counts `request.arrayBuffer()` bytes before one fatal UTF-8 decode, rejects a leading UTF-8 BOM/U+FEFF, and scans only the successfully decoded string. The existing 9,500,000-byte endpoint cap remains in force for requests without that field. +- Kotlin `Long` values serialized as JSON numbers must be in `-9_007_199_254_740_991L..9_007_199_254_740_991L` before entering a preference DTO. The current kotlinx.serialization wire emits JSON numbers, while Edge parses JavaScript `Number` values; out-of-range `baseRevision` and rack timestamps are dead-lettered locally for their current generation with an explicit diagnostic instead of being rounded or retried forever. +- Every preference JSON number destined for a Kotlin `Int` must be an integer in `-2_147_483_648..2_147_483_647`; every number destined for a Kotlin `Float` must survive `Math.fround` as finite and must not turn a nonzero input into zero. Every narrower Float business predicate applies to both the original JavaScript number and the narrowed Float32 result, so rounding cannot admit an out-of-range wire value. JavaScript safe-integer validation is reserved for Kotlin `Long` revisions and rack timestamps. +- Every preference string value and object key must be PostgreSQL-compatible Unicode scalar text: U+0000 and unpaired UTF-16 high/low surrogates are rejected recursively before any privileged client exists, while valid supplementary pairs are accepted. Mutation Unicode validation occurs per non-duplicate, in-limit section inside its local rejection boundary, after envelope-level raw-span integrity checks; one invalid section must not suppress valid siblings. +- Preference timestamps use one strict timezone-bearing RFC3339/ISO-instant parser with explicit calendar, day, time, fraction, and offset validation. Mutation audit timestamps, RPC canonicals, and pull canonicals share it; canonical output is normalized with `toISOString()`. +- Mobile preference diagnostics use fixed category names only. Logs never print a raw profile id, remote section string, `ProfilePreferenceSectionKey`, exception message, JSON/string/numeric value, or payload fragment; trusted local section enums and sanitized HTTP status codes are allowed. +- Preference sections are sent only after an ordinary payload has sent `allProfiles`. Each preference-only body is freshly constructed from `deviceId`, `platform`, `lastSync`, and `profilePreferenceSections`; it carries no active `profileId`, `profileName`, `allProfiles`, or ordinary entity data and is never attached to workout/session batches. +- Pull never creates, renames, deletes, or resurrects a local profile. Unknown `localProfileId` values are ignored and reported only as a sanitized count/category; the raw id is never logged. +- Direct `PUBLIC`, `anon`, and `authenticated` DML on `public.local_profile_preferences` is revoked. Remote mutation is Edge-only. +- Edge code verifies the user JWT, derives `user_id` from `auth.getUser`, and uses a server-only service-role client with an explicit verified `user_id` predicate for every privileged query. Returned Auth errors with status 400/401/403 are definitive credential rejection (401); 429, 5xx, missing/other status, malformed results, and thrown/rejected auth calls are operational failures (sanitized 503 with name-only logging). The service-role client is never constructed on either path. +- The complete line-ending-normalized Edge handoff artifact is sealed by a pinned SHA-256 literal in `BackendHandoffContractTest`, in addition to focused fragment/allowlist tests. +- The service-role secret is never sent to mobile, returned in an Edge response, written to logs, or copied into either handoff artifact. +- Backend code is not deployed from this repository. The repository produces executable SQL and an exact portal implementation contract only. +- Keep the handoff aligned with Supabase's official [RLS guidance](https://supabase.com/docs/guides/database/postgres/row-level-security), [Edge authorization-header guidance](https://supabase.com/docs/guides/functions/auth-headers), and [2026 Data API exposure change](https://supabase.com/changelog/45329-breaking-change-tables-not-exposed-to-data-and-graphql-api-automatically); include these links in the Edge handoff artifact. +- Use PowerShell Gradle syntax on this Windows workspace and pass `-Pskip.supabase.check=true` to local verification commands. +- Preserve unrelated user changes and stage only files named by the current task. + +--- + +## Dependency Contract from the Data-Foundation Plan + +The data-foundation plan owns schema 43, the public profile façade, typed values, validation, and local document codecs. This plan consumes these exact foundation types without adding sync methods to public `UserProfileRepository` or `ProfilePreferencesRepository`: + +```kotlin +enum class ProfilePreferenceSectionName { CORE, RACK, WORKOUT, LED, VBT } + +data class ProfilePreferenceSection( + val value: T, + val raw: String? = null, + val validity: ProfilePreferenceValidity, + val metadata: ProfileSectionMetadata, +) + +object ProfilePreferencesValidator +object ProfilePreferencesCodec +``` + +The foundation also provides `CoreProfilePreferences`, `RackPreferences(version, items)`, `WorkoutPreferences`, `LedPreferences(colorScheme, ...)`, `VbtPreferences(enabled, ...)`, and the schema-43 `UserProfilePreferences` SQLDelight row. Local LED JSON deliberately omits row-owned `colorScheme`; local VBT JSON deliberately omits row-owned `enabled`. This plan's internal sync codec reconstructs those values into the approved complete wire wrappers and parses local JSON documents into JSON objects; it never serializes a stored JSON string as a nested string. + +The foundation must expose the required migration state used by the Koin binding: + +```kotlin +val requiredMigrationState: StateFlow + +sealed interface RequiredMigrationState { + data object NotStarted : RequiredMigrationState + data object Applying : RequiredMigrationState + data object Ready : RequiredMigrationState + data class Failed(val message: String) : RequiredMigrationState +} +``` + +This plan adds a focused internal `ProfilePreferenceSyncRepository` and `SqlDelightProfilePreferenceSyncRepository(database, codec)` over the same schema-43 table and codecs. Push outcomes are grouped into one SQL transaction per profile. Matching generations apply the canonical payload/revision and clear dirty state; a newer generation advances only the stored server revision while preserving the newer local payload, timestamp, generation, and dirty state. + +## File Structure + +### Backend handoff artifacts + +- Create: `docs/backend-handoff/profile-preferences-supabase.sql` — executable table, constraints, RLS, grants, canonical projection, and atomic service-role RPC. +- Create: `docs/backend-handoff/profile-preferences-edge-functions.md` — exact TypeScript request/response, authentication, validation, push, pull, concurrency, testing, and deployment contract. +- Create: `docs/backend-handoff/profile-preference-byte-goldens.json` — shared raw-JSON boundary recipes consumed by both Kotlin and Deno tests. +- Create: `shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/BackendHandoffContractTest.kt` — keeps the mobile-only handoff security and wire requirements from drifting. + +### Mobile sync implementation + +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/SyncModels.kt` — non-wire section keys, snapshots, canonicals, outcomes, and diagnostics. +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/PortalSyncDtos.kt` — additive serializable mutation/canonical/rejection DTOs and push/pull fields. +- Modify: `shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/VitruvianDatabase.sq` — dirty snapshot and generation/revision-guarded canonical merge queries. +- Create: `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/ProfilePreferenceSyncRepository.kt` — internal persistence boundary and SQLDelight implementation grouped transactionally per profile. +- Create: `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/ProfilePreferenceSyncCodec.kt` — strict typed-row-to-wire and canonical-to-column codec, including LED/VBT row-owned wrappers. +- Create: `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/PortalWireJson.kt` — one byte-identical JSON configuration for planning and transport. +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/PortalSyncAdapter.kt` — local valid section to push mutation. +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/PortalPullAdapter.kt` — canonical wire section validation and mapping. +- Create: `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/ProfilePreferenceSyncPlanner.kt` — deterministic 256 KiB/512 KiB chunk planning and generation ledgers. +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/PortalApiClient.kt` — shared JSON and transport-level preference size enforcement. +- Modify: `gradle/libs.versions.toml` and `shared/build.gradle.kts` — add the version-aligned Ktor MockEngine dependency to `commonTest` only. +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/SyncManager.kt` — readiness gating, metadata-first push, preference acknowledgements, per-logical-call rate limiting, and preference-first pull merge. +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/di/SyncModule.kt` — inject the required-migration readiness function. +- Modify: `shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakePortalApiClient.kt` — payload history, queued push responses, and pull profile capture. +- Create: `shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeProfilePreferenceSyncRepository.kt` — dirty snapshots and captured push/pull application results. +- Modify: `shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeSyncRepository.kt` — optional pull-merge event hook for preference-before-entity ordering evidence. + +### Focused tests + +- Create: `shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/ProfilePreferenceSyncDtosTest.kt`. +- Create: `shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/PortalSyncAdapterProfilePreferencesTest.kt`. +- Create: `shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/PortalPullAdapterProfilePreferencesTest.kt`. +- Create: `shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/ProfilePreferenceSyncPlannerTest.kt`. +- Create: `shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/sync/SqlDelightProfilePreferenceSyncRepositoryTest.kt`. +- Create: `shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/PortalApiClientProfilePreferenceLimitsTest.kt`. +- Create: `shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakePortalApiClientTest.kt`. +- Modify: `shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/ErrorClassificationTest.kt`. +- Create: `shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/SyncManagerProfilePreferencesTest.kt`. +- Modify: `shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/PortalPushLimitsTest.kt`. +- Modify: `shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/PortalPullPaginationTest.kt`. +- Modify: `shared/src/androidHostTest/kotlin/com/devil/phoenixproject/di/KoinModuleVerifyTest.kt` if constructor verification requires an explicit migration-state binding. + +--- + +### Task 1: Create the Supabase Table, Constraints, RLS, and Grant Handoff + +**Files:** +- Create: `docs/backend-handoff/profile-preferences-supabase.sql` +- Create: `shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/BackendHandoffContractTest.kt` + +**Interfaces:** +- Consumes: existing remote parent key `public.local_profiles(user_id uuid, id text)`. +- Produces: secured `public.local_profile_preferences(user_id, local_profile_id)` storage for Task 2's atomic RPC. + +- [ ] **Step 1: Write the failing SQL handoff contract test** + +```kotlin +package com.devil.phoenixproject.data.sync + +import com.devil.phoenixproject.data.auth.sha256 +import com.devil.phoenixproject.testutil.readProjectFile +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class BackendHandoffContractTest { + private fun sql(): String = assertNotNull( + readProjectFile("docs/backend-handoff/profile-preferences-supabase.sql"), + "Supabase handoff SQL must be tracked in the mobile repository", + ) + + private fun normalizedSql(): String = normalizedTopLevelStatements(sql()) + .joinToString(separator = "; ", postfix = ";") + + private fun normalizedTopLevelStatements(value: String): List = + topLevelStatements(value).map(::normalizeStatement) + + private fun normalizeStatement(value: String): String = + lowercaseOutsideLiterals(value.replace(Regex("""\s+"""), " ").trim()) + + private fun topLevelStatements(value: String): List { + val statements = mutableListOf() + val statement = StringBuilder() + var quote: Char? = null + var dollarDelimiter: String? = null + var inLineComment = false + var blockCommentDepth = 0 + var index = 0 + + fun finishStatement() { + statement.toString().trim().takeIf(String::isNotEmpty)?.let(statements::add) + statement.clear() + } + + while (index < value.length) { + val character = value[index] + val next = value.getOrNull(index + 1) + if (dollarDelimiter != null) { + if (value.startsWith(dollarDelimiter, index)) { + statement.append(dollarDelimiter) + index += dollarDelimiter.length + dollarDelimiter = null + } else { + statement.append(character) + index += 1 + } + continue + } + if (inLineComment) { + if (character == '\n' || character == '\r') { + statement.append(' ') + inLineComment = false + } + index += 1 + continue + } + if (blockCommentDepth > 0) { + when { + character == '/' && next == '*' -> { + blockCommentDepth += 1 + index += 2 + } + character == '*' && next == '/' -> { + blockCommentDepth -= 1 + index += 2 + if (blockCommentDepth == 0) statement.append(' ') + } + else -> index += 1 + } + continue + } + if (quote != null) { + statement.append(character) + if (character == quote && next == quote) { + statement.append(next) + index += 2 + } else { + if (character == quote) quote = null + index += 1 + } + continue + } + when { + character == '-' && next == '-' -> { + inLineComment = true + index += 2 + } + character == '/' && next == '*' -> { + blockCommentDepth = 1 + index += 2 + } + character == '\'' || character == '"' -> { + quote = character + statement.append(character) + index += 1 + } + character == '$' -> { + val delimiter = dollarQuoteDelimiterAt(value, index) + if (delimiter == null) { + statement.append(character) + index += 1 + } else { + dollarDelimiter = delimiter + statement.append(delimiter) + index += delimiter.length + } + } + character == ';' -> { + finishStatement() + index += 1 + } + else -> { + statement.append(character) + index += 1 + } + } + } + assertTrue(quote == null, "Unterminated quoted SQL token") + assertTrue(dollarDelimiter == null, "Unterminated dollar-quoted SQL body") + assertEquals(0, blockCommentDepth, "Unterminated SQL block comment") + finishStatement() + return statements + } + + private fun dollarQuoteDelimiterAt(value: String, index: Int): String? { + if (value.getOrNull(index) != '$') return null + val previous = value.getOrNull(index - 1) + if (previous != null && (previous.isLetterOrDigit() || previous == '_' || previous == '$')) { + return null + } + var cursor = index + 1 + if (value.getOrNull(cursor) == '$') return value.substring(index, cursor + 1) + val first = value.getOrNull(cursor) ?: return null + if (!(first.isLetter() || first == '_')) return null + cursor += 1 + while (cursor < value.length) { + when (val current = value[cursor]) { + '$' -> return value.substring(index, cursor + 1) + else -> if (current.isLetterOrDigit() || current == '_') cursor += 1 else return null + } + } + return null + } + + private fun lowercaseOutsideLiterals(value: String): String = buildString(value.length) { + var quote: Char? = null + var index = 0 + while (index < value.length) { + val character = value[index] + if (quote != null && character == quote && value.getOrNull(index + 1) == quote) { + append(character) + append(value[index + 1]) + index += 2 + continue + } + when { + quote != null -> { + append(character) + if (character == quote) quote = null + } + character == '\'' || character == '"' -> { + append(character) + quote = character + } + character == ' ' && + ((length > 0 && this[length - 1] == '(') || + value.getOrNull(index + 1) == ')') -> Unit + else -> append(character.lowercaseChar()) + } + index += 1 + } + } + + private fun tableEntries(sql: String): List { + val body = assertNotNull( + Regex( + """(?s)\bcreate table public[.]local_profile_preferences\s*[(](.*?)[)]\s*;""", + ).find(sql), + "Expected one executable local_profile_preferences table declaration", + ).groupValues[1] + return splitTopLevel(body) + } + + private fun splitTopLevel(value: String): List { + val entries = mutableListOf() + var depth = 0 + var inLiteral = false + var start = 0 + var index = 0 + while (index < value.length) { + val character = value[index] + if ( + character == '\'' && + inLiteral && + index + 1 < value.length && + value[index + 1] == '\'' + ) { + index += 2 + continue + } + when { + character == '\'' -> inLiteral = !inLiteral + !inLiteral && character == '(' -> depth += 1 + !inLiteral && character == ')' -> depth -= 1 + !inLiteral && character == ',' && depth == 0 -> { + entries += value.substring(start, index).trim() + start = index + 1 + } + } + index += 1 + } + entries += value.substring(start).trim() + return entries.filter { entry -> entry.isNotEmpty() } + } + + private fun exactPreflightStatement(): String { + val delimiter = "\$preflight\$" + return normalizeStatement( + """ + DO $delimiter + DECLARE + matching_key text[]; + BEGIN + IF to_regclass('public.local_profiles') IS NULL THEN + RAISE EXCEPTION + 'profile preferences preflight: public.local_profiles does not exist'; + END IF; + + SELECT array_agg(attribute.attname::text ORDER BY key_column.ordinality) + INTO matching_key + FROM pg_constraint constraint_row + CROSS JOIN LATERAL unnest(constraint_row.conkey) + WITH ORDINALITY AS key_column(attnum, ordinality) + JOIN pg_attribute attribute + ON attribute.attrelid = constraint_row.conrelid + AND attribute.attnum = key_column.attnum + WHERE constraint_row.conrelid = 'public.local_profiles'::regclass + AND constraint_row.contype IN ('p', 'u') + GROUP BY constraint_row.oid + HAVING array_agg(attribute.attname::text ORDER BY key_column.ordinality) + = ARRAY['user_id', 'id']::text[] + LIMIT 1; + + IF matching_key IS NULL THEN + RAISE EXCEPTION + 'profile preferences preflight: expected unique local_profiles(user_id, id) parent key'; + END IF; + END + $delimiter + """.trimIndent(), + ) + } + + private fun exactTask1SecurityStatements(): List = listOf( + "alter table public.local_profile_preferences enable row level security", + "create policy local_profile_preferences_owner_select " + + "on public.local_profile_preferences for select to authenticated " + + "using ((select auth.uid()) = user_id)", + "create policy local_profile_preferences_owner_insert " + + "on public.local_profile_preferences for insert to authenticated " + + "with check ((select auth.uid()) = user_id)", + "create policy local_profile_preferences_owner_update " + + "on public.local_profile_preferences for update to authenticated " + + "using ((select auth.uid()) = user_id) " + + "with check ((select auth.uid()) = user_id)", + "create policy local_profile_preferences_owner_delete " + + "on public.local_profile_preferences for delete to authenticated " + + "using ((select auth.uid()) = user_id)", + "revoke all on table public.local_profile_preferences from public", + "revoke all on table public.local_profile_preferences from anon", + "revoke all on table public.local_profile_preferences from authenticated", + "grant select, insert, update, delete on table " + + "public.local_profile_preferences to service_role", + ) + + private fun assertTask1ExecutableEnvelope(statements: List) { + assertEquals(13, statements.size) + assertEquals("begin", statements[0]) + assertEquals(exactPreflightStatement(), statements[1]) + assertTrue(statements[2].startsWith("create table public.local_profile_preferences(")) + assertEquals(exactTask1SecurityStatements(), statements.subList(3, 12)) + assertEquals("commit", statements[12]) + } + + @Test + fun sqlHandoffDeclaresTheExactProfilePreferenceSchema() { + val statements = normalizedTopLevelStatements(sql()) + assertTask1ExecutableEnvelope(statements) + val sql = statements.joinToString(separator = "; ", postfix = ";") + val entries = tableEntries(sql) + val columns = entries.filterNot { entry -> entry.startsWith("constraint ") } + assertEquals( + listOf( + "user_id uuid not null", + "local_profile_id text not null", + "schema_version integer not null default 1 check (schema_version = 1)", + "body_weight_kg double precision not null default 0", + "weight_unit text not null default 'LB'", + "weight_increment double precision not null default -1", + "core_revision bigint not null default 0 check (core_revision >= 0)", + "core_updated_at timestamptz not null default now()", + """equipment_rack jsonb not null default '{"version":1,"items":[]}'::jsonb""", + "rack_revision bigint not null default 0 check (rack_revision >= 0)", + "rack_updated_at timestamptz not null default now()", + """workout_preferences jsonb not null default '{"version":1}'::jsonb""", + "workout_revision bigint not null default 0 check (workout_revision >= 0)", + "workout_updated_at timestamptz not null default now()", + "led_color_scheme_id integer not null default 0", + """led_preferences jsonb not null default '{"version":1,"discoModeUnlocked":false}'::jsonb""", + "led_revision bigint not null default 0 check (led_revision >= 0)", + "led_updated_at timestamptz not null default now()", + "vbt_enabled boolean not null default true", + """vbt_preferences jsonb not null default '{"version":1,"velocityLossThresholdPercent":20,"autoEndOnVelocityLoss":false,"defaultScalingBasis":"MAX_WEIGHT_PR","verbalEncouragementEnabled":false,"vulgarModeEnabled":false,"vulgarTier":"STRONG","dominatrixModeUnlocked":false,"dominatrixModeActive":false}'::jsonb""", + "vbt_revision bigint not null default 0 check (vbt_revision >= 0)", + "vbt_updated_at timestamptz not null default now()", + "updated_at timestamptz generated always as (" + + "greatest(core_updated_at, rack_updated_at, workout_updated_at, " + + "led_updated_at, vbt_updated_at)) stored", + ), + columns, + ) + + val constraintPairs = entries + .filter { entry -> entry.startsWith("constraint ") } + .map { entry -> + val match = assertNotNull( + Regex("""^constraint ([a-z_][a-z0-9_]*)\b""").find(entry), + "Every table constraint must be explicitly named", + ) + match.groupValues[1] to entry + } + val constraints = constraintPairs.toMap() + assertEquals(constraintPairs.size, constraints.size, "Constraint names must be unique") + + val expectedConstraints = mapOf( + "local_profile_preferences_pkey" to + "constraint local_profile_preferences_pkey " + + "primary key (user_id, local_profile_id)", + "local_profile_preferences_parent_fkey" to + "constraint local_profile_preferences_parent_fkey " + + "foreign key (user_id, local_profile_id) " + + "references public.local_profiles(user_id, id) on delete cascade", + "local_profile_preferences_body_weight_check" to + "constraint local_profile_preferences_body_weight_check check (" + + "body_weight_kg not in (" + + "'NaN'::double precision, 'Infinity'::double precision, " + + "'-Infinity'::double precision) and " + + "(body_weight_kg = 0 or body_weight_kg between 20 and 300))", + "local_profile_preferences_weight_unit_check" to + "constraint local_profile_preferences_weight_unit_check " + + "check (weight_unit in ('KG', 'LB'))", + "local_profile_preferences_weight_increment_check" to + "constraint local_profile_preferences_weight_increment_check check (" + + "weight_increment not in (" + + "'NaN'::double precision, 'Infinity'::double precision, " + + "'-Infinity'::double precision) and " + + "(weight_increment = -1 or weight_increment > 0))", + "local_profile_preferences_led_scheme_check" to + "constraint local_profile_preferences_led_scheme_check " + + "check (led_color_scheme_id >= 0)", + "local_profile_preferences_rack_object_check" to + "constraint local_profile_preferences_rack_object_check check (" + + "jsonb_typeof(equipment_rack) = 'object' and " + + """equipment_rack @> '{"version":1}'::jsonb and """ + + "jsonb_typeof(equipment_rack -> 'items') = 'array' and " + + "not jsonb_path_exists(" + + "equipment_rack, '$.items[*] ? (@.weightKg < 0)') and " + + "octet_length(equipment_rack::text) <= 262144)", + "local_profile_preferences_workout_object_check" to + "constraint local_profile_preferences_workout_object_check check (" + + "jsonb_typeof(workout_preferences) = 'object' and " + + """workout_preferences @> '{"version":1}'::jsonb and """ + + "(not workout_preferences ? 'summaryCountdownSeconds' or (" + + "jsonb_typeof(workout_preferences -> 'summaryCountdownSeconds') = " + + "'number' and " + + "(workout_preferences ->> 'summaryCountdownSeconds')::integer " + + "in (-1, 0, 5, 10, 15, 20, 25, 30))) and " + + "(not workout_preferences ? 'autoStartCountdownSeconds' or (" + + "jsonb_typeof(workout_preferences -> 'autoStartCountdownSeconds') = " + + "'number' and " + + "(workout_preferences ->> 'autoStartCountdownSeconds')::integer " + + "between 2 and 10)) and " + + "(not workout_preferences ? " + + "'defaultRoutineExerciseWeightPercentOfPR' or (" + + "jsonb_typeof(workout_preferences -> " + + "'defaultRoutineExerciseWeightPercentOfPR') = 'number' and " + + "(workout_preferences ->> " + + "'defaultRoutineExerciseWeightPercentOfPR')::integer " + + "between 50 and 120)) and " + + "octet_length(workout_preferences::text) <= 262144)", + "local_profile_preferences_led_object_check" to + "constraint local_profile_preferences_led_object_check check (" + + "jsonb_typeof(led_preferences) = 'object' and " + + """led_preferences @> '{"version":1}'::jsonb and """ + + "jsonb_typeof(led_preferences -> 'discoModeUnlocked') = " + + "'boolean' and octet_length(led_preferences::text) <= 262144)", + "local_profile_preferences_vbt_object_check" to + "constraint local_profile_preferences_vbt_object_check check (" + + "jsonb_typeof(vbt_preferences) = 'object' and " + + """vbt_preferences @> '{"version":1}'::jsonb and """ + + "jsonb_typeof(vbt_preferences -> " + + "'velocityLossThresholdPercent') = 'number' and " + + "(vbt_preferences ->> 'velocityLossThresholdPercent')::integer " + + "between 10 and 50 and " + + "octet_length(vbt_preferences::text) <= 262144)", + ) + assertEquals(expectedConstraints, constraints) + + assertEquals( + "updated_at timestamptz generated always as (" + + "greatest(core_updated_at, rack_updated_at, workout_updated_at, " + + "led_updated_at, vbt_updated_at)) stored", + columns.single { column -> column.startsWith("updated_at ") }, + ) + + val aggregate = "array_agg(attribute.attname::text order by key_column.ordinality)" + assertEquals(2, Regex(Regex.escape(aggregate)).findAll(sql).count()) + assertTrue(sql.contains("select " + aggregate + " into matching_key")) + assertTrue( + sql.contains( + "having " + aggregate + " = array['user_id', 'id']::text[]", + ), + ) + + val workoutConstraint = constraints.getValue( + "local_profile_preferences_workout_object_check", + ) + val countdownSets = Regex( + """[(]workout_preferences ->> 'summaryCountdownSeconds'[)]::integer """ + + """in [(]([^)]*)[)]""", + ).findAll(workoutConstraint).toList() + assertEquals(1, countdownSets.size) + assertEquals("-1, 0, 5, 10, 15, 20, 25, 30", countdownSets.single().groupValues[1]) + + mapOf( + "local_profile_preferences_rack_object_check" to + "octet_length(equipment_rack::text) <= 262144", + "local_profile_preferences_workout_object_check" to + "octet_length(workout_preferences::text) <= 262144", + "local_profile_preferences_led_object_check" to + "octet_length(led_preferences::text) <= 262144", + "local_profile_preferences_vbt_object_check" to + "octet_length(vbt_preferences::text) <= 262144", + ).forEach { (name, ceiling) -> + assertTrue(constraints.getValue(name).contains(ceiling)) + } + } + + @Test + fun sqlHandoffSecuresTheExactProfilePreferenceSurface() { + val statements = normalizedTopLevelStatements(sql()) + assertTask1ExecutableEnvelope(statements) + val sql = statements.joinToString(separator = "; ", postfix = ";") + assertTrue( + statements.contains( + "alter table public.local_profile_preferences enable row level security", + ), + ) + assertFalse( + Regex("""\bdisable\s+row\s+level\s+security\b""").containsMatchIn(sql), + "The handoff must never disable RLS", + ) + + val expectedPolicies = mapOf( + "local_profile_preferences_owner_select" to + "create policy local_profile_preferences_owner_select " + + "on public.local_profile_preferences for select to authenticated " + + "using ((select auth.uid()) = user_id)", + "local_profile_preferences_owner_insert" to + "create policy local_profile_preferences_owner_insert " + + "on public.local_profile_preferences for insert to authenticated " + + "with check ((select auth.uid()) = user_id)", + "local_profile_preferences_owner_update" to + "create policy local_profile_preferences_owner_update " + + "on public.local_profile_preferences for update to authenticated " + + "using ((select auth.uid()) = user_id) " + + "with check ((select auth.uid()) = user_id)", + "local_profile_preferences_owner_delete" to + "create policy local_profile_preferences_owner_delete " + + "on public.local_profile_preferences for delete to authenticated " + + "using ((select auth.uid()) = user_id)", + ) + val createPolicyPattern = Regex("""^create\s+policy\b""") + val policyStatements = statements.filter { statement -> + createPolicyPattern.containsMatchIn(statement) + } + assertEquals(expectedPolicies.size, policyStatements.size) + assertEquals(expectedPolicies.values.toSet(), policyStatements.toSet()) + assertEquals( + expectedPolicies.keys, + policyStatements + .map { statement -> + statement.removePrefix("create policy ").substringBefore(" on ") + } + .toSet(), + ) + + val forbiddenPolicyDdl = Regex("""^(?:alter|drop)\s+policy\b""") + assertFalse( + statements.any { statement -> forbiddenPolicyDdl.containsMatchIn(statement) }, + "ALTER POLICY and DROP POLICY are forbidden in the handoff", + ) + + assertTrue("revoke all on table public.local_profile_preferences from public" in statements) + assertTrue("revoke all on table public.local_profile_preferences from anon" in statements) + assertTrue( + "revoke all on table public.local_profile_preferences from authenticated" in statements, + ) + + val grantPattern = Regex( + """^grant\s+(.+?)\s+on\s+(?:table\s+)?(.+?)\s+to\s+(.+)$""", + ) + val tableGrants = statements.mapNotNull { statement -> + grantPattern.matchEntire(statement) + }.filter { grant -> + grant.groupValues[2].split(',').any { target -> + target.trim() == "public.local_profile_preferences" + } + } + val forbiddenClientRole = Regex("""\b(?:public|anon|authenticated)\b""") + assertFalse( + tableGrants.any { grant -> + forbiddenClientRole.containsMatchIn(grant.groupValues[3]) + }, + "Client roles must not receive direct table grants", + ) + assertEquals(1, tableGrants.size, "Only the service-role table grant is allowed") + assertEquals( + "select, insert, update, delete", + tableGrants.single().groupValues[1].trim(), + ) + assertEquals("public.local_profile_preferences", tableGrants.single().groupValues[2].trim()) + assertEquals("service_role", tableGrants.single().groupValues[3].trim()) + + val forbiddenLocalFieldPatterns = mapOf( + "local safety or consent" to Regex( + """(?i)\b[a-z0-9_]*(?:local_?safety|safe_?word(?:_?(?:calibrated|calibration))?|""" + + """adults?_?only_?(?:confirmed|prompted|consent)|adult_?consent)\b""", + ), + "local generation" to Regex("""(?i)\b[a-z0-9_]*local_?generation\b"""), + "dirty state" to Regex("""(?i)\b[a-z0-9_]*dirty\b"""), + "legacy migration version" to + Regex( + """(?i)\b[a-z0-9_]*(?:legacy_migration_version|""" + + """legacymigrationversion)\b""", + ), + ) + forbiddenLocalFieldPatterns.forEach { (field, pattern) -> + assertFalse( + pattern.containsMatchIn(sql), + "Local-only field must not enter backend SQL: $field", + ) + } + } +} +``` + +- [ ] **Step 2: Run the test and verify the missing artifact failure** + +Run: + +```powershell +.\gradlew.bat :shared:testAndroidHostTest --tests "com.devil.phoenixproject.data.sync.BackendHandoffContractTest" -Pskip.supabase.check=true +``` + +Expected: FAIL because `docs/backend-handoff/profile-preferences-supabase.sql` does not exist. + +- [ ] **Step 3: Create the executable SQL preflight and table** + +Create `docs/backend-handoff/profile-preferences-supabase.sql` with this table contract. Keep the preflight before all DDL so a mismatched portal schema aborts without partial changes. + +```sql +BEGIN; + +DO $preflight$ +DECLARE + matching_key text[]; +BEGIN + IF to_regclass('public.local_profiles') IS NULL THEN + RAISE EXCEPTION 'profile preferences preflight: public.local_profiles does not exist'; + END IF; + + SELECT array_agg(attribute.attname::text ORDER BY key_column.ordinality) + INTO matching_key + FROM pg_constraint constraint_row + CROSS JOIN LATERAL unnest(constraint_row.conkey) + WITH ORDINALITY AS key_column(attnum, ordinality) + JOIN pg_attribute attribute + ON attribute.attrelid = constraint_row.conrelid + AND attribute.attnum = key_column.attnum + WHERE constraint_row.conrelid = 'public.local_profiles'::regclass + AND constraint_row.contype IN ('p', 'u') + GROUP BY constraint_row.oid + HAVING array_agg(attribute.attname::text ORDER BY key_column.ordinality) + = ARRAY['user_id', 'id']::text[] + LIMIT 1; + + IF matching_key IS NULL THEN + RAISE EXCEPTION + 'profile preferences preflight: expected unique local_profiles(user_id, id) parent key'; + END IF; +END +$preflight$; + +CREATE TABLE public.local_profile_preferences ( + user_id uuid NOT NULL, + local_profile_id text NOT NULL, + schema_version integer NOT NULL DEFAULT 1 CHECK (schema_version = 1), + + body_weight_kg double precision NOT NULL DEFAULT 0, + weight_unit text NOT NULL DEFAULT 'LB', + weight_increment double precision NOT NULL DEFAULT -1, + core_revision bigint NOT NULL DEFAULT 0 CHECK (core_revision >= 0), + core_updated_at timestamptz NOT NULL DEFAULT now(), + + equipment_rack jsonb NOT NULL DEFAULT '{"version":1,"items":[]}'::jsonb, + rack_revision bigint NOT NULL DEFAULT 0 CHECK (rack_revision >= 0), + rack_updated_at timestamptz NOT NULL DEFAULT now(), + + workout_preferences jsonb NOT NULL DEFAULT '{"version":1}'::jsonb, + workout_revision bigint NOT NULL DEFAULT 0 CHECK (workout_revision >= 0), + workout_updated_at timestamptz NOT NULL DEFAULT now(), + + led_color_scheme_id integer NOT NULL DEFAULT 0, + led_preferences jsonb NOT NULL DEFAULT '{"version":1,"discoModeUnlocked":false}'::jsonb, + led_revision bigint NOT NULL DEFAULT 0 CHECK (led_revision >= 0), + led_updated_at timestamptz NOT NULL DEFAULT now(), + + vbt_enabled boolean NOT NULL DEFAULT true, + vbt_preferences jsonb NOT NULL DEFAULT '{"version":1,"velocityLossThresholdPercent":20,"autoEndOnVelocityLoss":false,"defaultScalingBasis":"MAX_WEIGHT_PR","verbalEncouragementEnabled":false,"vulgarModeEnabled":false,"vulgarTier":"STRONG","dominatrixModeUnlocked":false,"dominatrixModeActive":false}'::jsonb, + vbt_revision bigint NOT NULL DEFAULT 0 CHECK (vbt_revision >= 0), + vbt_updated_at timestamptz NOT NULL DEFAULT now(), + + updated_at timestamptz GENERATED ALWAYS AS ( + greatest(core_updated_at, rack_updated_at, workout_updated_at, led_updated_at, vbt_updated_at) + ) STORED, + + CONSTRAINT local_profile_preferences_pkey PRIMARY KEY (user_id, local_profile_id), + CONSTRAINT local_profile_preferences_parent_fkey + FOREIGN KEY (user_id, local_profile_id) + REFERENCES public.local_profiles(user_id, id) + ON DELETE CASCADE, + CONSTRAINT local_profile_preferences_body_weight_check CHECK ( + body_weight_kg NOT IN ('NaN'::double precision, 'Infinity'::double precision, '-Infinity'::double precision) + AND (body_weight_kg = 0 OR body_weight_kg BETWEEN 20 AND 300) + ), + CONSTRAINT local_profile_preferences_weight_unit_check CHECK (weight_unit IN ('KG', 'LB')), + CONSTRAINT local_profile_preferences_weight_increment_check CHECK ( + weight_increment NOT IN ('NaN'::double precision, 'Infinity'::double precision, '-Infinity'::double precision) + AND (weight_increment = -1 OR weight_increment > 0) + ), + CONSTRAINT local_profile_preferences_led_scheme_check CHECK (led_color_scheme_id >= 0), + CONSTRAINT local_profile_preferences_rack_object_check CHECK ( + jsonb_typeof(equipment_rack) = 'object' + AND equipment_rack @> '{"version":1}'::jsonb + AND jsonb_typeof(equipment_rack -> 'items') = 'array' + AND NOT jsonb_path_exists(equipment_rack, '$.items[*] ? (@.weightKg < 0)') + AND octet_length(equipment_rack::text) <= 262144 + ), + CONSTRAINT local_profile_preferences_workout_object_check CHECK ( + jsonb_typeof(workout_preferences) = 'object' + AND workout_preferences @> '{"version":1}'::jsonb + AND ( + NOT workout_preferences ? 'summaryCountdownSeconds' + OR ( + jsonb_typeof(workout_preferences -> 'summaryCountdownSeconds') = 'number' + AND (workout_preferences ->> 'summaryCountdownSeconds')::integer + IN (-1, 0, 5, 10, 15, 20, 25, 30) + ) + ) + AND ( + NOT workout_preferences ? 'autoStartCountdownSeconds' + OR ( + jsonb_typeof(workout_preferences -> 'autoStartCountdownSeconds') = 'number' + AND (workout_preferences ->> 'autoStartCountdownSeconds')::integer BETWEEN 2 AND 10 + ) + ) + AND ( + NOT workout_preferences ? 'defaultRoutineExerciseWeightPercentOfPR' + OR ( + jsonb_typeof(workout_preferences -> 'defaultRoutineExerciseWeightPercentOfPR') = 'number' + AND (workout_preferences ->> 'defaultRoutineExerciseWeightPercentOfPR')::integer BETWEEN 50 AND 120 + ) + ) + AND octet_length(workout_preferences::text) <= 262144 + ), + CONSTRAINT local_profile_preferences_led_object_check CHECK ( + jsonb_typeof(led_preferences) = 'object' + AND led_preferences @> '{"version":1}'::jsonb + AND jsonb_typeof(led_preferences -> 'discoModeUnlocked') = 'boolean' + AND octet_length(led_preferences::text) <= 262144 + ), + CONSTRAINT local_profile_preferences_vbt_object_check CHECK ( + jsonb_typeof(vbt_preferences) = 'object' + AND vbt_preferences @> '{"version":1}'::jsonb + AND jsonb_typeof(vbt_preferences -> 'velocityLossThresholdPercent') = 'number' + AND (vbt_preferences ->> 'velocityLossThresholdPercent')::integer BETWEEN 10 AND 50 + AND octet_length(vbt_preferences::text) <= 262144 + ) +); +``` + +- [ ] **Step 4: Add RLS policies and explicit Data API privileges** + +Append this security block before the final `COMMIT`: + +```sql +ALTER TABLE public.local_profile_preferences ENABLE ROW LEVEL SECURITY; + +CREATE POLICY local_profile_preferences_owner_select + ON public.local_profile_preferences + FOR SELECT TO authenticated + USING ((select auth.uid()) = user_id); + +CREATE POLICY local_profile_preferences_owner_insert + ON public.local_profile_preferences + FOR INSERT TO authenticated + WITH CHECK ((select auth.uid()) = user_id); + +CREATE POLICY local_profile_preferences_owner_update + ON public.local_profile_preferences + FOR UPDATE TO authenticated + USING ((select auth.uid()) = user_id) + WITH CHECK ((select auth.uid()) = user_id); + +CREATE POLICY local_profile_preferences_owner_delete + ON public.local_profile_preferences + FOR DELETE TO authenticated + USING ((select auth.uid()) = user_id); + +REVOKE ALL ON TABLE public.local_profile_preferences FROM PUBLIC; +REVOKE ALL ON TABLE public.local_profile_preferences FROM anon; +REVOKE ALL ON TABLE public.local_profile_preferences FROM authenticated; +GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.local_profile_preferences TO service_role; + +COMMIT; +``` + +- [ ] **Step 5: Run the contract test and verify it passes** + +Run: + +```powershell +.\gradlew.bat :shared:testAndroidHostTest --tests "com.devil.phoenixproject.data.sync.BackendHandoffContractTest" -Pskip.supabase.check=true +``` + +Expected: PASS. + +- [ ] **Step 6: Commit the secured table handoff** + +```powershell +git add docs/backend-handoff/profile-preferences-supabase.sql shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/BackendHandoffContractTest.kt +git commit -m "docs(sync): add secured profile preferences schema handoff" +``` + +--- + +### Task 2: Add the Atomic Mutation RPC and Edge Function Contract + +**Files:** +- Modify: `docs/backend-handoff/profile-preferences-supabase.sql` +- Create: `docs/backend-handoff/profile-preferences-edge-functions.md` +- Create: `docs/backend-handoff/profile-preference-byte-goldens.json` +- Modify: `shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/BackendHandoffContractTest.kt` + +**Interfaces:** +- Consumes: secured table from Task 1. +- Produces: service-role-only `mutate_local_profile_preference_section` RPC and exact portal push/pull implementation instructions. + +- [ ] **Step 1: Extend the contract test for atomic mutation and Edge authorization** + +Extend the committed Task 1 test rather than adding a second SQL parser. Replace +`assertTask1ExecutableEnvelope` with `assertFinalExecutableEnvelope`, update both schema/security +tests to call the final helper, and keep Task 1's quote/comment/dollar-aware +`topLevelStatements` → `normalizeStatement` pipeline. The table-only commit is intentionally +13 statements; this Task 2 RED step changes the final contract to the exact 19-statement chain. +Add imports for `com.devil.phoenixproject.data.auth.sha256` and `assertNotEquals`, then add these helpers/tests to `BackendHandoffContractTest`. The known SHA-256-of-empty-string value is an intentional RED sentinel, not an accepted final hash. Only after Step 5 writes and reviews the complete Edge handoff may its actual LF-normalized digest replace the RHS of `EXPECTED_EDGE_HANDOFF_SHA256`; the final value must be a concrete independent 64-character lowercase literal and must never be computed from `edgeContract()` at assertion time. + +```kotlin +private fun edgeContract(): String = assertNotNull( + readProjectFile("docs/backend-handoff/profile-preferences-edge-functions.md"), + "Edge Function handoff must be tracked in the mobile repository", +) + +private fun byteGoldenArtifact(): String = assertNotNull( + readProjectFile("docs/backend-handoff/profile-preference-byte-goldens.json"), + "Shared profile preference byte goldens must be tracked in the mobile repository", +) + +private val exactByteGoldenArtifact = """ +{ + "version": 1, + "paddingMarker": "__ASCII_PADDING__", + "sectionMarker": "__SECTION_JSON__", + "sectionRawTemplate": "{\"localProfileId\":\"profile-a\",\"section\":\"RACK\",\"documentVersion\":1,\"baseRevision\":0,\"clientModifiedAt\":\"2026-07-11T12:00:00Z\",\"payload\":{\"version\":1,\"items\":[{\"id\":\"rack-a\",\"name\":\"π界🙂\\\"\\\\__ASCII_PADDING__\",\"category\":\"OTHER\",\"weightKg\":20.0,\"behavior\":\"DISPLAY_ONLY\",\"enabled\":true,\"sortOrder\":0,\"createdAt\":-1e3,\"updatedAt\":0}]}}", + "requestRawTemplate": "{\"deviceId\":\"golden-device\",\"platform\":\"android\",\"lastSync\":0,\"profileId\":\"profile-a\",\"profileName\":\"π界🙂\\\"\\\\__ASCII_PADDING__\",\"profilePreferenceSections\":[__SECTION_JSON__]}", + "sectionTargetBytes": [262143, 262144, 262145], + "requestTargetBytes": [524287, 524288, 524289] +} +""".trimIndent() + +private fun normalizedTrackedText(value: String): String = + value.replace("\r\n", "\n").removeSuffix("\n") + +private val SHA256_EMPTY_RED_SENTINEL = + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + +// Task 2 RED only. Step 5 replaces this RHS with the reviewed artifact's concrete digest. +private val EXPECTED_EDGE_HANDOFF_SHA256 = SHA256_EMPTY_RED_SENTINEL + +private fun normalizedEdgeHandoff(value: String): String = + value.replace("\r\n", "\n").replace('\r', '\n') + +private fun ByteArray.lowerHex(): String = joinToString("") { + (it.toInt() and 0xff).toString(16).padStart(2, '0') +} + +private fun edgeHandoffSha256(value: String): String = + sha256(normalizedEdgeHandoff(value).encodeToByteArray()).lowerHex() + +private fun executableTypeScript(): String = Regex( + pattern = """(?s)```typescript\s+(.*?)```""", +).findAll(edgeContract()) + .joinToString("\n") { match -> match.groupValues[1] } + .replace(Regex("""(?s)/[*].*?[*]/"""), " ") + .lineSequence() + .map { line -> line.substringBefore("//") } + .joinToString("\n") + +private fun portalTestManifest(): Set { + val matches = Regex("""(?s)```portal-test-manifest\s+(.*?)```""") + .findAll(edgeContract()) + .toList() + assertEquals(1, matches.size, "Expected exactly one portal test manifest") + val lines = matches.single().groupValues[1] + .lineSequence() + .map(String::trim) + .filter(String::isNotEmpty) + .toList() + assertEquals(lines.size, lines.toSet().size, "Portal test manifest entries must be unique") + return lines.toSet() +} + +private fun exactFunctionAclStatements(): List = listOf( + "revoke all on function public.local_profile_preference_section_canonical(" + + "public.local_profile_preferences, text) from public, anon, authenticated", + "revoke all on function public.mutate_local_profile_preference_section(" + + "uuid, text, text, integer, bigint, jsonb) from public, anon, authenticated", + "grant execute on function public.local_profile_preference_section_canonical(" + + "public.local_profile_preferences, text) to service_role", + "grant execute on function public.mutate_local_profile_preference_section(" + + "uuid, text, text, integer, bigint, jsonb) to service_role", +) + +/* + * Deliberately duplicate the complete Step 3 and Step 4 SQL blocks as raw triple-quoted + * literals. These constants are independent test oracles; never derive them from sql(). + * The literal contents are exactly the full CREATE FUNCTION statements shown below, + * including every branch and dollar-quoted body. + */ +private val EXACT_CANONICAL_FUNCTION_SQL = """ +CREATE FUNCTION public.local_profile_preference_section_canonical( + p_row public.local_profile_preferences, + p_section text +) RETURNS jsonb +LANGUAGE sql +STABLE +SECURITY INVOKER +SET search_path = '' +AS ${'$'}canonical${'$'} + SELECT jsonb_build_object( + 'localProfileId', p_row.local_profile_id, + 'section', p_section, + 'documentVersion', CASE p_section + WHEN 'CORE' THEN 1 + WHEN 'RACK' THEN (p_row.equipment_rack ->> 'version')::integer + WHEN 'WORKOUT' THEN (p_row.workout_preferences ->> 'version')::integer + WHEN 'LED' THEN (p_row.led_preferences ->> 'version')::integer + WHEN 'VBT' THEN (p_row.vbt_preferences ->> 'version')::integer + END, + 'serverRevision', CASE p_section + WHEN 'CORE' THEN p_row.core_revision + WHEN 'RACK' THEN p_row.rack_revision + WHEN 'WORKOUT' THEN p_row.workout_revision + WHEN 'LED' THEN p_row.led_revision + WHEN 'VBT' THEN p_row.vbt_revision + END, + 'serverUpdatedAt', to_char( + (CASE p_section + WHEN 'CORE' THEN p_row.core_updated_at + WHEN 'RACK' THEN p_row.rack_updated_at + WHEN 'WORKOUT' THEN p_row.workout_updated_at + WHEN 'LED' THEN p_row.led_updated_at + WHEN 'VBT' THEN p_row.vbt_updated_at + END) AT TIME ZONE 'UTC', + 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"' + ), + 'payload', CASE p_section + WHEN 'CORE' THEN jsonb_build_object( + 'bodyWeightKg', p_row.body_weight_kg, + 'weightUnit', p_row.weight_unit, + 'weightIncrement', p_row.weight_increment + ) + WHEN 'RACK' THEN p_row.equipment_rack + WHEN 'WORKOUT' THEN p_row.workout_preferences + WHEN 'LED' THEN jsonb_build_object( + 'ledColorSchemeId', p_row.led_color_scheme_id, + 'preferences', p_row.led_preferences + ) + WHEN 'VBT' THEN jsonb_build_object( + 'vbtEnabled', p_row.vbt_enabled, + 'preferences', p_row.vbt_preferences + ) + END + ); +${'$'}canonical${'$'} +""" + +private val EXACT_MUTATION_FUNCTION_SQL = """ +CREATE FUNCTION public.mutate_local_profile_preference_section( + p_user_id uuid, + p_local_profile_id text, + p_section text, + p_document_version integer, + p_base_revision bigint, + p_payload jsonb +) RETURNS TABLE ( + accepted boolean, + rejection_reason text, + server_revision bigint, + canonical_section jsonb +) +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = '' +AS ${'$'}mutation${'$'} +DECLARE + current_row public.local_profile_preferences%ROWTYPE; + current_revision bigint; +BEGIN + IF p_section IS NULL OR p_section NOT IN ('CORE', 'RACK', 'WORKOUT', 'LED', 'VBT') THEN + RETURN QUERY SELECT false, 'UNSUPPORTED_SECTION', 0::bigint, NULL::jsonb; + RETURN; + END IF; + IF p_document_version IS NULL OR p_document_version <> 1 THEN + RETURN QUERY SELECT false, 'UNSUPPORTED_DOCUMENT_VERSION', 0::bigint, NULL::jsonb; + RETURN; + END IF; + IF p_base_revision IS NULL OR p_base_revision < 0 + OR p_payload IS NULL OR jsonb_typeof(p_payload) <> 'object' THEN + RETURN QUERY SELECT false, 'VALIDATION_FAILED', 0::bigint, NULL::jsonb; + RETURN; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM public.local_profiles + WHERE user_id = p_user_id AND id = p_local_profile_id + ) THEN + RETURN QUERY SELECT false, 'UNKNOWN_PROFILE', 0::bigint, NULL::jsonb; + RETURN; + END IF; + + CASE p_section + WHEN 'CORE' THEN + UPDATE public.local_profile_preferences + SET body_weight_kg = (p_payload ->> 'bodyWeightKg')::double precision, + weight_unit = p_payload ->> 'weightUnit', + weight_increment = (p_payload ->> 'weightIncrement')::double precision, + core_revision = core_revision + 1, + core_updated_at = clock_timestamp() + WHERE user_id = p_user_id + AND local_profile_id = p_local_profile_id + AND core_revision = p_base_revision + RETURNING * INTO current_row; + WHEN 'RACK' THEN + UPDATE public.local_profile_preferences + SET equipment_rack = p_payload, + rack_revision = rack_revision + 1, + rack_updated_at = clock_timestamp() + WHERE user_id = p_user_id + AND local_profile_id = p_local_profile_id + AND rack_revision = p_base_revision + RETURNING * INTO current_row; + WHEN 'WORKOUT' THEN + UPDATE public.local_profile_preferences + SET workout_preferences = p_payload, + workout_revision = workout_revision + 1, + workout_updated_at = clock_timestamp() + WHERE user_id = p_user_id + AND local_profile_id = p_local_profile_id + AND workout_revision = p_base_revision + RETURNING * INTO current_row; + WHEN 'LED' THEN + UPDATE public.local_profile_preferences + SET led_color_scheme_id = (p_payload ->> 'ledColorSchemeId')::integer, + led_preferences = p_payload -> 'preferences', + led_revision = led_revision + 1, + led_updated_at = clock_timestamp() + WHERE user_id = p_user_id + AND local_profile_id = p_local_profile_id + AND led_revision = p_base_revision + RETURNING * INTO current_row; + WHEN 'VBT' THEN + UPDATE public.local_profile_preferences + SET vbt_enabled = (p_payload ->> 'vbtEnabled')::boolean, + vbt_preferences = p_payload -> 'preferences', + vbt_revision = vbt_revision + 1, + vbt_updated_at = clock_timestamp() + WHERE user_id = p_user_id + AND local_profile_id = p_local_profile_id + AND vbt_revision = p_base_revision + RETURNING * INTO current_row; + END CASE; + + IF FOUND THEN + canonical_section := public.local_profile_preference_section_canonical(current_row, p_section); + server_revision := (canonical_section ->> 'serverRevision')::bigint; + RETURN QUERY SELECT true, NULL::text, server_revision, canonical_section; + RETURN; + END IF; + + IF p_base_revision = 0 THEN + INSERT INTO public.local_profile_preferences ( + user_id, local_profile_id, + body_weight_kg, weight_unit, weight_increment, core_revision, + equipment_rack, rack_revision, + workout_preferences, workout_revision, + led_color_scheme_id, led_preferences, led_revision, + vbt_enabled, vbt_preferences, vbt_revision + ) VALUES ( + p_user_id, + p_local_profile_id, + CASE WHEN p_section = 'CORE' THEN (p_payload ->> 'bodyWeightKg')::double precision ELSE 0 END, + CASE WHEN p_section = 'CORE' THEN p_payload ->> 'weightUnit' ELSE 'LB' END, + CASE WHEN p_section = 'CORE' THEN (p_payload ->> 'weightIncrement')::double precision ELSE -1 END, + CASE WHEN p_section = 'CORE' THEN 1 ELSE 0 END, + CASE WHEN p_section = 'RACK' THEN p_payload ELSE '{"version":1,"items":[]}'::jsonb END, + CASE WHEN p_section = 'RACK' THEN 1 ELSE 0 END, + CASE WHEN p_section = 'WORKOUT' THEN p_payload ELSE '{"version":1}'::jsonb END, + CASE WHEN p_section = 'WORKOUT' THEN 1 ELSE 0 END, + CASE WHEN p_section = 'LED' THEN (p_payload ->> 'ledColorSchemeId')::integer ELSE 0 END, + CASE WHEN p_section = 'LED' THEN p_payload -> 'preferences' ELSE '{"version":1,"discoModeUnlocked":false}'::jsonb END, + CASE WHEN p_section = 'LED' THEN 1 ELSE 0 END, + CASE WHEN p_section = 'VBT' THEN (p_payload ->> 'vbtEnabled')::boolean ELSE true END, + CASE WHEN p_section = 'VBT' THEN p_payload -> 'preferences' ELSE '{"version":1,"velocityLossThresholdPercent":20,"autoEndOnVelocityLoss":false,"defaultScalingBasis":"MAX_WEIGHT_PR","verbalEncouragementEnabled":false,"vulgarModeEnabled":false,"vulgarTier":"STRONG","dominatrixModeUnlocked":false,"dominatrixModeActive":false}'::jsonb END, + CASE WHEN p_section = 'VBT' THEN 1 ELSE 0 END + ) + ON CONFLICT (user_id, local_profile_id) DO NOTHING + RETURNING * INTO current_row; + + IF FOUND THEN + canonical_section := public.local_profile_preference_section_canonical(current_row, p_section); + server_revision := (canonical_section ->> 'serverRevision')::bigint; + RETURN QUERY SELECT true, NULL::text, server_revision, canonical_section; + RETURN; + END IF; + + CASE p_section + WHEN 'CORE' THEN + UPDATE public.local_profile_preferences + SET body_weight_kg = (p_payload ->> 'bodyWeightKg')::double precision, + weight_unit = p_payload ->> 'weightUnit', + weight_increment = (p_payload ->> 'weightIncrement')::double precision, + core_revision = 1, + core_updated_at = clock_timestamp() + WHERE user_id = p_user_id AND local_profile_id = p_local_profile_id AND core_revision = 0 + RETURNING * INTO current_row; + WHEN 'RACK' THEN + UPDATE public.local_profile_preferences + SET equipment_rack = p_payload, rack_revision = 1, rack_updated_at = clock_timestamp() + WHERE user_id = p_user_id AND local_profile_id = p_local_profile_id AND rack_revision = 0 + RETURNING * INTO current_row; + WHEN 'WORKOUT' THEN + UPDATE public.local_profile_preferences + SET workout_preferences = p_payload, workout_revision = 1, workout_updated_at = clock_timestamp() + WHERE user_id = p_user_id AND local_profile_id = p_local_profile_id AND workout_revision = 0 + RETURNING * INTO current_row; + WHEN 'LED' THEN + UPDATE public.local_profile_preferences + SET led_color_scheme_id = (p_payload ->> 'ledColorSchemeId')::integer, + led_preferences = p_payload -> 'preferences', + led_revision = 1, + led_updated_at = clock_timestamp() + WHERE user_id = p_user_id AND local_profile_id = p_local_profile_id AND led_revision = 0 + RETURNING * INTO current_row; + WHEN 'VBT' THEN + UPDATE public.local_profile_preferences + SET vbt_enabled = (p_payload ->> 'vbtEnabled')::boolean, + vbt_preferences = p_payload -> 'preferences', + vbt_revision = 1, + vbt_updated_at = clock_timestamp() + WHERE user_id = p_user_id AND local_profile_id = p_local_profile_id AND vbt_revision = 0 + RETURNING * INTO current_row; + END CASE; + + IF FOUND THEN + canonical_section := public.local_profile_preference_section_canonical(current_row, p_section); + server_revision := (canonical_section ->> 'serverRevision')::bigint; + RETURN QUERY SELECT true, NULL::text, server_revision, canonical_section; + RETURN; + END IF; + END IF; + + SELECT * INTO current_row + FROM public.local_profile_preferences + WHERE user_id = p_user_id AND local_profile_id = p_local_profile_id; + + IF NOT FOUND THEN + RETURN QUERY SELECT false, 'REVISION_CONFLICT', 0::bigint, NULL::jsonb; + RETURN; + END IF; + + canonical_section := public.local_profile_preference_section_canonical(current_row, p_section); + current_revision := (canonical_section ->> 'serverRevision')::bigint; + RETURN QUERY SELECT false, 'REVISION_CONFLICT', current_revision, canonical_section; +END +${'$'}mutation${'$'} +""" + +private val exactCanonicalFunctionStatement = + normalizeStatement(EXACT_CANONICAL_FUNCTION_SQL) +private val exactMutationFunctionStatement = + normalizeStatement(EXACT_MUTATION_FUNCTION_SQL) + +private fun assertFinalExecutableEnvelope(statements: List) { + assertEquals(19, statements.size, "Expected the exact final atomic statement chain") + assertEquals("begin", statements[0]) + assertEquals(exactPreflightStatement(), statements[1]) + assertTrue(statements[2].startsWith("create table public.local_profile_preferences(")) + assertEquals(exactCanonicalFunctionStatement, statements[3]) + assertEquals(exactMutationFunctionStatement, statements[4]) + assertEquals(exactFunctionAclStatements(), statements.subList(5, 9)) + assertEquals(exactTask1SecurityStatements(), statements.subList(9, 18)) + assertEquals("commit", statements[18]) +} + +@Test +fun `sql handoff has the exact functions ACLs and 19 statement envelope`() { + val statements = normalizedTopLevelStatements(sql()) + assertFinalExecutableEnvelope(statements) + assertEquals(2, statements.count { it.startsWith("create function ") }) + assertEquals( + exactFunctionAclStatements(), + statements.filter { " on function " in it }, + ) + val tableAcls = statements.filter { + (" on table public.local_profile_preferences " in it) && + (it.startsWith("revoke ") || it.startsWith("grant ")) + } + assertEquals(exactTask1SecurityStatements().takeLast(4), tableAcls) +} + +@Test +fun `exact function bodies reject executable additions`() { + val valid = sql() + val mutations = mapOf( + "canonical DELETE" to valid.replace( + " SELECT jsonb_build_object(", + " DELETE FROM public.local_profile_preferences;\n" + + " SELECT jsonb_build_object(", + ), + "mutation PERFORM" to valid.replace( + "BEGIN\n IF p_section IS NULL", + "BEGIN\n PERFORM now();\n IF p_section IS NULL", + ), + "mutation dblink_exec" to valid.replace( + "BEGIN\n IF p_section IS NULL", + "BEGIN\n PERFORM dblink_exec('remote', 'DELETE FROM public.local_profiles');\n" + + " IF p_section IS NULL", + ), + "mutation role change" to valid.replace( + "BEGIN\n IF p_section IS NULL", + "BEGIN\n SET LOCAL ROLE authenticated;\n IF p_section IS NULL", + ), + "mutation transaction control" to valid.replace( + "BEGIN\n IF p_section IS NULL", + "BEGIN\n COMMIT;\n IF p_section IS NULL", + ), + ) + mutations.forEach { (name, mutation) -> + assertFailsWith("Must reject $name") { + assertFinalExecutableEnvelope(normalizedTopLevelStatements(mutation)) + } + } +} + +@Test +fun `edge executable contract authenticates validates fully then performs scoped admin calls`() { + val code = executableTypeScript() + listOf( + "auth.getUser(userJwt)", + "bearerMatch", + "authOperationalFailure", + "status === 400 || status === 401 || status === 403", + "Object.hasOwn(authRecord, \"error\")", + "Object.hasOwn(authRecord, \"data\")", + "{ status: 503 }", + "req.arrayBuffer()", + "TextDecoder(\"utf-8\", { fatal: true", + "hasLeadingUtf8Bom", + "SUPABASE_SERVICE_ROLE_KEY", + "parsePreferenceEnvelope", + "validateCorePayload", + "validateRackPayload", + "validateWorkoutPayload", + "validateLedPayload", + "validateVbtPayload", + "LOCAL_ONLY_KEYS", + "requirePostgresTextTree", + "requireInt32", + "requireFloat32", + "!predicate(value)", + "!predicate(narrowed)", + "requireSafeJsonLong", + "requireRfc3339Instant", + "INT32_MIN = -2_147_483_648", + "originalBodyBytes.byteLength", + "MAX_PROFILE_PREFERENCE_SECTION_BYTES = 262_144", + "MAX_PROFILE_PREFERENCE_REQUEST_BYTES = 524_288", + "scanTopLevelJsonObject", + "preferenceElementSpans", + "rawPreferenceElementBytes", + "rawBodyBytes > MAX_PROFILE_PREFERENCE_REQUEST_BYTES", + "duplicateIdentities", + "validatedMutations", + "admin.rpc(\"mutate_local_profile_preference_section\"", + "p_user_id: verifiedUserId", + ".eq(\"user_id\", verifiedUserId)", + ".eq(\"local_profile_id\", requestedProfileId)", + "throw new PreferenceInfrastructureError", + "console.error({ name: safeErrorName(error", + "normalized.toISOString()", + ).forEach { fragment -> assertTrue(fragment in code, fragment) } + + fun quotedInitializer(pattern: String): List { + val body = assertNotNull( + Regex(pattern).find(code), + "Missing exact TypeScript allowlist: $pattern", + ).groupValues[1] + return Regex(""""([^"]+)"""") + .findAll(body) + .map { match -> match.groupValues[1] } + .toList() + } + assertEquals( + listOf( + "deviceId", "platform", "lastSync", "sessions", "telemetry", "routines", + "deletedRoutineIds", "cycles", "deletedCycleIds", "rpgAttributes", "badges", + "gamificationStats", "phaseStatistics", "exerciseSignatures", "assessments", + "customExercises", "profileId", "profileName", "allProfiles", + "externalActivities", "personalRecords", "profilePreferenceSections", + ), + quotedInitializer( + """(?s)const PUSH_BODY_KEYS = new Set[(][[](.+?)[]][)];""", + ), + ) + assertEquals( + listOf( + "localProfileId", "section", "documentVersion", "baseRevision", + "clientModifiedAt", "payload", + ), + quotedInitializer("""(?s)const MUTATION_KEYS = [[](.+?)[]] as const;"""), + ) + assertEquals( + listOf( + "safeword", "safewordcalibrated", "adultsonlyconfirmed", + "adultsonlyprompted", "localgeneration", "dirty", "legacymigrationversion", + ), + quotedInitializer( + """(?s)const LOCAL_ONLY_KEYS = new Set[(][[](.+?)[]][)];""", + ), + ) + + assertTrue("rawBodyBytes > MAX_PROFILE_PREFERENCE_REQUEST_BYTES" in code) + assertFalse("requirePostgresTextTree(rawMutation" in code) + assertFalse("console.error(\"profile preference infrastructure failure\"" in code) + assertFalse(Regex("""\buserId\s*[?:]?\s*:\s*string""").containsMatchIn(code)) + assertFalse(Regex("""console[.](?:log|info|warn|error)[(][^)]*SERVICE_ROLE""").containsMatchIn(code)) +} + +@Test +fun `shared byte golden artifact is the exact cross-language oracle`() { + assertEquals( + exactByteGoldenArtifact, + normalizedTrackedText(byteGoldenArtifact()), + ) +} + +@Test +fun `complete normalized Edge handoff is sealed by a pinned digest`() { + val original = normalizedEdgeHandoff(edgeContract()) + val actual = edgeHandoffSha256(original) + assertEquals( + EXPECTED_EDGE_HANDOFF_SHA256, + actual, + "After writing and reviewing the exact handoff, pin this digest: $actual", + ) + assertNotEquals( + SHA256_EMPTY_RED_SENTINEL, + EXPECTED_EDGE_HANDOFF_SHA256, + "The Task 2 RED sentinel must not survive Step 5", + ) + + val appended = original + + "\n```typescript\nthrow new Error(\"appended executable mutation\");\n```\n" + val inverted = original.replaceFirst( + "rawBodyBytes > MAX_PROFILE_PREFERENCE_REQUEST_BYTES", + "rawBodyBytes <= MAX_PROFILE_PREFERENCE_REQUEST_BYTES", + ) + assertNotEquals(original, inverted, "Digest mutation target must exist") + assertNotEquals(EXPECTED_EDGE_HANDOFF_SHA256, edgeHandoffSha256(appended)) + assertNotEquals(EXPECTED_EDGE_HANDOFF_SHA256, edgeHandoffSha256(inverted)) +} + +@Test +fun `edge handoff names executable portal tests rather than prose-only assurances`() { + assertEquals( + setOf( + "database:exact-function-acls-and-no-client-dml", + "database:temporary-grant-owner-rls-and-cross-owner-user-id-protection", + "database:base-revision-accept-and-stale-canonical-conflict", + "edge:auth-and-cross-user-profile-rejection", + "edge:auth-rejection-vs-operational-outage-classification", + "edge:strict-five-section-validation-and-local-only-rejection", + "edge:kotlin-int32-float32-unicode-and-rfc3339-parity", + "edge:fatal-utf8-bom-and-original-byte-enforcement", + "edge:section-262143-262144-262145-byte-boundaries", + "edge:envelope-524287-524288-524289-byte-boundaries", + "edge:unexpected-rpc-error-is-sanitized-5xx", + "edge:same-section-concurrent-first-write", + "edge:different-section-concurrent-first-write", + "edge:lost-ack-retry-canonical-convergence", + "edge:mutation-and-first-page-pull-canonical-equality", + "edge:later-pull-pages-omit-preferences-and-keep-sync-time", + ), + portalTestManifest(), + ) +} +``` + +- [ ] **Step 2: Run the test and verify the missing RPC/document failures** + +Run: + +```powershell +.\gradlew.bat :shared:testAndroidHostTest --tests "com.devil.phoenixproject.data.sync.BackendHandoffContractTest" -Pskip.supabase.check=true +``` + +Expected: FAIL because the RPC/Edge handoff and hardening fragments are absent; after the handoff is first written, the deliberate empty-digest RED sentinel still fails until Step 5 pins the reviewed complete artifact digest. + +- [ ] **Step 3: Add a canonical-section SQL projection** + +Insert this function before Task 1's privilege block and `COMMIT`: + +```sql +CREATE FUNCTION public.local_profile_preference_section_canonical( + p_row public.local_profile_preferences, + p_section text +) RETURNS jsonb +LANGUAGE sql +STABLE +SECURITY INVOKER +SET search_path = '' +AS $canonical$ + SELECT jsonb_build_object( + 'localProfileId', p_row.local_profile_id, + 'section', p_section, + 'documentVersion', CASE p_section + WHEN 'CORE' THEN 1 + WHEN 'RACK' THEN (p_row.equipment_rack ->> 'version')::integer + WHEN 'WORKOUT' THEN (p_row.workout_preferences ->> 'version')::integer + WHEN 'LED' THEN (p_row.led_preferences ->> 'version')::integer + WHEN 'VBT' THEN (p_row.vbt_preferences ->> 'version')::integer + END, + 'serverRevision', CASE p_section + WHEN 'CORE' THEN p_row.core_revision + WHEN 'RACK' THEN p_row.rack_revision + WHEN 'WORKOUT' THEN p_row.workout_revision + WHEN 'LED' THEN p_row.led_revision + WHEN 'VBT' THEN p_row.vbt_revision + END, + 'serverUpdatedAt', to_char( + (CASE p_section + WHEN 'CORE' THEN p_row.core_updated_at + WHEN 'RACK' THEN p_row.rack_updated_at + WHEN 'WORKOUT' THEN p_row.workout_updated_at + WHEN 'LED' THEN p_row.led_updated_at + WHEN 'VBT' THEN p_row.vbt_updated_at + END) AT TIME ZONE 'UTC', + 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"' + ), + 'payload', CASE p_section + WHEN 'CORE' THEN jsonb_build_object( + 'bodyWeightKg', p_row.body_weight_kg, + 'weightUnit', p_row.weight_unit, + 'weightIncrement', p_row.weight_increment + ) + WHEN 'RACK' THEN p_row.equipment_rack + WHEN 'WORKOUT' THEN p_row.workout_preferences + WHEN 'LED' THEN jsonb_build_object( + 'ledColorSchemeId', p_row.led_color_scheme_id, + 'preferences', p_row.led_preferences + ) + WHEN 'VBT' THEN jsonb_build_object( + 'vbtEnabled', p_row.vbt_enabled, + 'preferences', p_row.vbt_preferences + ) + END + ); +$canonical$; +``` + +- [ ] **Step 4: Add the service-role-only revision-checked RPC** + +Add the following RPC immediately after the canonical projection and before the privilege block/`COMMIT`. Its five branches are explicit so typed columns and JSON documents cannot be accidentally swapped. The function call is one Postgres transaction, so the conflict fetch observes the same atomic operation. + +```sql +CREATE FUNCTION public.mutate_local_profile_preference_section( + p_user_id uuid, + p_local_profile_id text, + p_section text, + p_document_version integer, + p_base_revision bigint, + p_payload jsonb +) RETURNS TABLE ( + accepted boolean, + rejection_reason text, + server_revision bigint, + canonical_section jsonb +) +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = '' +AS $mutation$ +DECLARE + current_row public.local_profile_preferences%ROWTYPE; + current_revision bigint; +BEGIN + IF p_section IS NULL OR p_section NOT IN ('CORE', 'RACK', 'WORKOUT', 'LED', 'VBT') THEN + RETURN QUERY SELECT false, 'UNSUPPORTED_SECTION', 0::bigint, NULL::jsonb; + RETURN; + END IF; + IF p_document_version IS NULL OR p_document_version <> 1 THEN + RETURN QUERY SELECT false, 'UNSUPPORTED_DOCUMENT_VERSION', 0::bigint, NULL::jsonb; + RETURN; + END IF; + IF p_base_revision IS NULL OR p_base_revision < 0 + OR p_payload IS NULL OR jsonb_typeof(p_payload) <> 'object' THEN + RETURN QUERY SELECT false, 'VALIDATION_FAILED', 0::bigint, NULL::jsonb; + RETURN; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM public.local_profiles + WHERE user_id = p_user_id AND id = p_local_profile_id + ) THEN + RETURN QUERY SELECT false, 'UNKNOWN_PROFILE', 0::bigint, NULL::jsonb; + RETURN; + END IF; + + CASE p_section + WHEN 'CORE' THEN + UPDATE public.local_profile_preferences + SET body_weight_kg = (p_payload ->> 'bodyWeightKg')::double precision, + weight_unit = p_payload ->> 'weightUnit', + weight_increment = (p_payload ->> 'weightIncrement')::double precision, + core_revision = core_revision + 1, + core_updated_at = clock_timestamp() + WHERE user_id = p_user_id + AND local_profile_id = p_local_profile_id + AND core_revision = p_base_revision + RETURNING * INTO current_row; + WHEN 'RACK' THEN + UPDATE public.local_profile_preferences + SET equipment_rack = p_payload, + rack_revision = rack_revision + 1, + rack_updated_at = clock_timestamp() + WHERE user_id = p_user_id + AND local_profile_id = p_local_profile_id + AND rack_revision = p_base_revision + RETURNING * INTO current_row; + WHEN 'WORKOUT' THEN + UPDATE public.local_profile_preferences + SET workout_preferences = p_payload, + workout_revision = workout_revision + 1, + workout_updated_at = clock_timestamp() + WHERE user_id = p_user_id + AND local_profile_id = p_local_profile_id + AND workout_revision = p_base_revision + RETURNING * INTO current_row; + WHEN 'LED' THEN + UPDATE public.local_profile_preferences + SET led_color_scheme_id = (p_payload ->> 'ledColorSchemeId')::integer, + led_preferences = p_payload -> 'preferences', + led_revision = led_revision + 1, + led_updated_at = clock_timestamp() + WHERE user_id = p_user_id + AND local_profile_id = p_local_profile_id + AND led_revision = p_base_revision + RETURNING * INTO current_row; + WHEN 'VBT' THEN + UPDATE public.local_profile_preferences + SET vbt_enabled = (p_payload ->> 'vbtEnabled')::boolean, + vbt_preferences = p_payload -> 'preferences', + vbt_revision = vbt_revision + 1, + vbt_updated_at = clock_timestamp() + WHERE user_id = p_user_id + AND local_profile_id = p_local_profile_id + AND vbt_revision = p_base_revision + RETURNING * INTO current_row; + END CASE; + + IF FOUND THEN + canonical_section := public.local_profile_preference_section_canonical(current_row, p_section); + server_revision := (canonical_section ->> 'serverRevision')::bigint; + RETURN QUERY SELECT true, NULL::text, server_revision, canonical_section; + RETURN; + END IF; + + IF p_base_revision = 0 THEN + INSERT INTO public.local_profile_preferences ( + user_id, local_profile_id, + body_weight_kg, weight_unit, weight_increment, core_revision, + equipment_rack, rack_revision, + workout_preferences, workout_revision, + led_color_scheme_id, led_preferences, led_revision, + vbt_enabled, vbt_preferences, vbt_revision + ) VALUES ( + p_user_id, + p_local_profile_id, + CASE WHEN p_section = 'CORE' THEN (p_payload ->> 'bodyWeightKg')::double precision ELSE 0 END, + CASE WHEN p_section = 'CORE' THEN p_payload ->> 'weightUnit' ELSE 'LB' END, + CASE WHEN p_section = 'CORE' THEN (p_payload ->> 'weightIncrement')::double precision ELSE -1 END, + CASE WHEN p_section = 'CORE' THEN 1 ELSE 0 END, + CASE WHEN p_section = 'RACK' THEN p_payload ELSE '{"version":1,"items":[]}'::jsonb END, + CASE WHEN p_section = 'RACK' THEN 1 ELSE 0 END, + CASE WHEN p_section = 'WORKOUT' THEN p_payload ELSE '{"version":1}'::jsonb END, + CASE WHEN p_section = 'WORKOUT' THEN 1 ELSE 0 END, + CASE WHEN p_section = 'LED' THEN (p_payload ->> 'ledColorSchemeId')::integer ELSE 0 END, + CASE WHEN p_section = 'LED' THEN p_payload -> 'preferences' ELSE '{"version":1,"discoModeUnlocked":false}'::jsonb END, + CASE WHEN p_section = 'LED' THEN 1 ELSE 0 END, + CASE WHEN p_section = 'VBT' THEN (p_payload ->> 'vbtEnabled')::boolean ELSE true END, + CASE WHEN p_section = 'VBT' THEN p_payload -> 'preferences' ELSE '{"version":1,"velocityLossThresholdPercent":20,"autoEndOnVelocityLoss":false,"defaultScalingBasis":"MAX_WEIGHT_PR","verbalEncouragementEnabled":false,"vulgarModeEnabled":false,"vulgarTier":"STRONG","dominatrixModeUnlocked":false,"dominatrixModeActive":false}'::jsonb END, + CASE WHEN p_section = 'VBT' THEN 1 ELSE 0 END + ) + ON CONFLICT (user_id, local_profile_id) DO NOTHING + RETURNING * INTO current_row; + + IF FOUND THEN + canonical_section := public.local_profile_preference_section_canonical(current_row, p_section); + server_revision := (canonical_section ->> 'serverRevision')::bigint; + RETURN QUERY SELECT true, NULL::text, server_revision, canonical_section; + RETURN; + END IF; + + CASE p_section + WHEN 'CORE' THEN + UPDATE public.local_profile_preferences + SET body_weight_kg = (p_payload ->> 'bodyWeightKg')::double precision, + weight_unit = p_payload ->> 'weightUnit', + weight_increment = (p_payload ->> 'weightIncrement')::double precision, + core_revision = 1, + core_updated_at = clock_timestamp() + WHERE user_id = p_user_id AND local_profile_id = p_local_profile_id AND core_revision = 0 + RETURNING * INTO current_row; + WHEN 'RACK' THEN + UPDATE public.local_profile_preferences + SET equipment_rack = p_payload, rack_revision = 1, rack_updated_at = clock_timestamp() + WHERE user_id = p_user_id AND local_profile_id = p_local_profile_id AND rack_revision = 0 + RETURNING * INTO current_row; + WHEN 'WORKOUT' THEN + UPDATE public.local_profile_preferences + SET workout_preferences = p_payload, workout_revision = 1, workout_updated_at = clock_timestamp() + WHERE user_id = p_user_id AND local_profile_id = p_local_profile_id AND workout_revision = 0 + RETURNING * INTO current_row; + WHEN 'LED' THEN + UPDATE public.local_profile_preferences + SET led_color_scheme_id = (p_payload ->> 'ledColorSchemeId')::integer, + led_preferences = p_payload -> 'preferences', + led_revision = 1, + led_updated_at = clock_timestamp() + WHERE user_id = p_user_id AND local_profile_id = p_local_profile_id AND led_revision = 0 + RETURNING * INTO current_row; + WHEN 'VBT' THEN + UPDATE public.local_profile_preferences + SET vbt_enabled = (p_payload ->> 'vbtEnabled')::boolean, + vbt_preferences = p_payload -> 'preferences', + vbt_revision = 1, + vbt_updated_at = clock_timestamp() + WHERE user_id = p_user_id AND local_profile_id = p_local_profile_id AND vbt_revision = 0 + RETURNING * INTO current_row; + END CASE; + + IF FOUND THEN + canonical_section := public.local_profile_preference_section_canonical(current_row, p_section); + server_revision := (canonical_section ->> 'serverRevision')::bigint; + RETURN QUERY SELECT true, NULL::text, server_revision, canonical_section; + RETURN; + END IF; + END IF; + + SELECT * INTO current_row + FROM public.local_profile_preferences + WHERE user_id = p_user_id AND local_profile_id = p_local_profile_id; + + IF NOT FOUND THEN + RETURN QUERY SELECT false, 'REVISION_CONFLICT', 0::bigint, NULL::jsonb; + RETURN; + END IF; + + canonical_section := public.local_profile_preference_section_canonical(current_row, p_section); + current_revision := (canonical_section ->> 'serverRevision')::bigint; + RETURN QUERY SELECT false, 'REVISION_CONFLICT', current_revision, canonical_section; +END +$mutation$; + +REVOKE ALL ON FUNCTION public.local_profile_preference_section_canonical( + public.local_profile_preferences, text +) FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION public.mutate_local_profile_preference_section( + uuid, text, text, integer, bigint, jsonb +) FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.local_profile_preference_section_canonical( + public.local_profile_preferences, text +) TO service_role; +GRANT EXECUTE ON FUNCTION public.mutate_local_profile_preference_section( + uuid, text, text, integer, bigint, jsonb +) TO service_role; +``` + +- [ ] **Step 5: Create the exact Edge Function handoff document** + +Create `docs/backend-handoff/profile-preferences-edge-functions.md` as a local-only backend handoff. It tells the portal implementer exactly what to create and test; this mobile-repository task does not deploy an Edge Function, apply a remote migration, or mutate the Supabase project. Name these portal targets exactly: + +```text +supabase/functions/mobile-sync-push/index.ts +supabase/functions/mobile-sync-push/index.test.ts +supabase/functions/mobile-sync-pull/index.ts +supabase/functions/mobile-sync-pull/index.test.ts +supabase/functions/_shared/profile-preference-byte-goldens.json +supabase/tests/database/profile_preferences.test.sql +supabase/config.toml +supabase/migrations/ (created by: supabase migration new profile_preferences) +docs/profile-preferences-advisor-dispositions.md +``` + +Create `docs/backend-handoff/profile-preference-byte-goldens.json` with this exact content. The portal implementer copies it byte-for-byte to `supabase/functions/_shared/profile-preference-byte-goldens.json`; the Kotlin contract test compares the tracked object exactly, and the Deno tests compare the portal copy's SHA-256 to the handoff artifact before using it: + +```json +{ + "version": 1, + "paddingMarker": "__ASCII_PADDING__", + "sectionMarker": "__SECTION_JSON__", + "sectionRawTemplate": "{\"localProfileId\":\"profile-a\",\"section\":\"RACK\",\"documentVersion\":1,\"baseRevision\":0,\"clientModifiedAt\":\"2026-07-11T12:00:00Z\",\"payload\":{\"version\":1,\"items\":[{\"id\":\"rack-a\",\"name\":\"π界🙂\\\"\\\\__ASCII_PADDING__\",\"category\":\"OTHER\",\"weightKg\":20.0,\"behavior\":\"DISPLAY_ONLY\",\"enabled\":true,\"sortOrder\":0,\"createdAt\":-1e3,\"updatedAt\":0}]}}", + "requestRawTemplate": "{\"deviceId\":\"golden-device\",\"platform\":\"android\",\"lastSync\":0,\"profileId\":\"profile-a\",\"profileName\":\"π界🙂\\\"\\\\__ASCII_PADDING__\",\"profilePreferenceSections\":[__SECTION_JSON__]}", + "sectionTargetBytes": [262143, 262144, 262145], + "requestTargetBytes": [524287, 524288, 524289] +} +``` + +Both languages implement the same recipe: parse only the artifact wrapper, preserve each raw-template string verbatim, replace the section padding marker with enough ASCII `x` bytes to reach the requested section target, and assert that the marker occurs exactly once. For a request golden, first replace `__SECTION_JSON__` with the valid section template containing one ASCII padding byte, then replace the request padding marker with enough ASCII `x` bytes to reach the requested complete-body target. Compute padding from UTF-8 byte counts, not character counts, and assert the final count equals its target. Both test suites assert that the generated raw JSON retains the decimal lexeme `20.0`, exponent lexeme `-1e3`, escaped quote and backslash, and multibyte `π界🙂`. Kotlin owns parity between these raw spans and the real kotlinx mutation/request decoders plus the mobile scanner; it does not exercise an HTTP raw-body handler. Deno owns enforcement through the real raw Edge handler, including 400/413 behavior, privileged-call suppression, and inclusive size boundaries. These are shared cross-language fixtures, not independently reconstructed goldens. + +Start the handoff with the current official [Edge authorization guidance](https://supabase.com/docs/guides/functions/auth), [RLS guidance](https://supabase.com/docs/guides/database/postgres/row-level-security), and [Data API exposure change](https://supabase.com/changelog/45329-breaking-change-tables-not-exposed-to-data-and-graphql-api-automatically). Use these concrete TypeScript types: + +```typescript +type JsonRecord = Record; +type ProfilePreferenceSection = "CORE" | "RACK" | "WORKOUT" | "LED" | "VBT"; + +interface PortalProfilePreferenceSectionMutation { + localProfileId: string; + section: ProfilePreferenceSection; + documentVersion: number; + baseRevision: number; + clientModifiedAt: string; + payload: Record; +} + +interface PortalProfilePreferenceSectionCanonical { + localProfileId: string; + section: ProfilePreferenceSection; + documentVersion: number; + serverRevision: number; + serverUpdatedAt: string; + payload: Record; +} + +interface ProfilePreferenceSectionRejection { + localProfileId: string; + section: string; + serverRevision: number; + reason: + | "REVISION_CONFLICT" + | "VALIDATION_FAILED" + | "UNSUPPORTED_SECTION" + | "UNSUPPORTED_DOCUMENT_VERSION" + | "SECTION_TOO_LARGE" + | "DUPLICATE_SECTION" + | "UNKNOWN_PROFILE"; + canonicalSection?: PortalProfilePreferenceSectionCanonical; +} + +interface MobileSyncPushAdditions { + profilePreferenceSections?: PortalProfilePreferenceSectionMutation[]; +} + +interface MobileSyncPushResponseAdditions { + profilePreferencesAccepted?: boolean; + canonicalProfilePreferenceSections: PortalProfilePreferenceSectionCanonical[]; + profilePreferenceRejections: ProfilePreferenceSectionRejection[]; +} + +interface MobileSyncPullResponseAdditions { + profilePreferenceSections?: PortalProfilePreferenceSectionCanonical[]; +} +``` + +`REVISION_CONFLICT`, `VALIDATION_FAILED`, `UNSUPPORTED_SECTION`, `UNSUPPORTED_DOCUMENT_VERSION`, and `UNKNOWN_PROFILE` are domain rejections returned by the RPC and may coexist with successful sibling sections. `SECTION_TOO_LARGE` and `DUPLICATE_SECTION` are Edge validation rejections. A missing/malformed bearer header or a returned `auth.getUser` Auth error whose numeric status is exactly 400, 401, or 403 is a definitive credential rejection and returns 401. Returned Auth errors with 429, 5xx, any other/missing status, malformed success/no-user results, and thrown/rejected calls are operational auth failures: return a generic 503, log only `{ name }`, and never construct the admin client. Transport, PostgREST/RPC, permission, timeout, malformed-RPC-row, and pull-query failures are likewise sanitized infrastructure 5xx responses and are never relabeled as domain rejection. Document version `1` is the only supported version; any other wrapper version, or an embedded `version` other than `1`, uses `UNSUPPORTED_DOCUMENT_VERSION` instead of a generic validation reason. + +Put these executable runtime parsers in `mobile-sync-push/index.ts` (or import them from a tested sibling module without changing their behavior). This is the complete request allowlist and the complete version-1 schema for all five wrappers; no schema library may silently strip unknown keys or coerce strings into numbers/booleans: + +```typescript +type ValidationReason = + | "VALIDATION_FAILED" + | "UNSUPPORTED_SECTION" + | "UNSUPPORTED_DOCUMENT_VERSION"; + +class PreferenceValidationError extends Error { + constructor( + readonly reason: ValidationReason, + readonly field: string, + ) { + super("Invalid profile preference field: " + field); + this.name = "PreferenceValidationError"; + } +} + +class PreferenceInfrastructureError extends Error { + constructor(readonly operation: string) { + super("Profile preference infrastructure failure"); + this.name = "PreferenceInfrastructureError"; + } +} + +const MAX_PROFILE_PREFERENCE_SECTION_BYTES = 262_144; +const MAX_PROFILE_PREFERENCE_REQUEST_BYTES = 524_288; +const MAX_MOBILE_SYNC_REQUEST_BYTES = 9_500_000; +const INT32_MIN = -2_147_483_648; +const INT32_MAX = 2_147_483_647; +const utf8Bytes = (rawJson: string): number => + new TextEncoder().encode(rawJson).byteLength; + +const PUSH_BODY_KEYS = new Set([ + "deviceId", + "platform", + "lastSync", + "sessions", + "telemetry", + "routines", + "deletedRoutineIds", + "cycles", + "deletedCycleIds", + "rpgAttributes", + "badges", + "gamificationStats", + "phaseStatistics", + "exerciseSignatures", + "assessments", + "customExercises", + "profileId", + "profileName", + "allProfiles", + "externalActivities", + "personalRecords", + "profilePreferenceSections", +]); + +const MUTATION_KEYS = [ + "localProfileId", + "section", + "documentVersion", + "baseRevision", + "clientModifiedAt", + "payload", +] as const; + +const LOCAL_ONLY_KEYS = new Set([ + "safeword", + "safewordcalibrated", + "adultsonlyconfirmed", + "adultsonlyprompted", + "localgeneration", + "dirty", + "legacymigrationversion", +]); +const normalizeKey = (key: string): string => + key.replace(/[^a-z0-9]/gi, "").toLowerCase(); + +const fail = ( + field: string, + reason: ValidationReason = "VALIDATION_FAILED", +): never => { + throw new PreferenceValidationError(reason, field); +}; + +const requireRecord = (value: unknown, field: string): JsonRecord => { + if (typeof value !== "object" || value === null || Array.isArray(value)) fail(field); + return value as JsonRecord; +}; + +const requirePostgresString = (value: unknown, field: string): string => { + if (typeof value !== "string") fail(field); + for (let index = 0; index < value.length; index += 1) { + const codeUnit = value.charCodeAt(index); + if (codeUnit === 0) fail(field); + if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (!(next >= 0xdc00 && next <= 0xdfff)) fail(field); + index += 1; + } else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) { + fail(field); + } + } + return value; +}; + +function requirePostgresTextTree(value: unknown, field: string): void { + if (typeof value === "string") { + requirePostgresString(value, field); + return; + } + if (Array.isArray(value)) { + value.forEach((child, index) => requirePostgresTextTree(child, field + "[" + index + "]")); + return; + } + if (typeof value !== "object" || value === null) return; + Object.entries(value as JsonRecord).forEach(([key, child]) => { + requirePostgresString(key, field + "."); + requirePostgresTextTree(child, field + "." + key); + }); +} + +const requireExactRecord = ( + value: unknown, + keys: readonly string[], + field: string, +): JsonRecord => { + const record = requireRecord(value, field); + requirePostgresTextTree(record, field); + const allowed = new Set(keys); + for (const key of Object.keys(record)) { + if (!allowed.has(key)) fail(field + "." + key); + } + for (const key of keys) { + if (!Object.hasOwn(record, key)) fail(field + "." + key); + } + return record; +}; + +const requireKnownKeys = ( + record: JsonRecord, + allowed: ReadonlySet, + field: string, +): void => { + for (const key of Object.keys(record)) { + if (!allowed.has(key)) fail(field + "." + key); + } +}; + +const requireArray = (value: unknown, field: string): unknown[] => { + if (!Array.isArray(value)) fail(field); + return value; +}; + +interface RawJsonSpan { + start: number; + end: number; +} + +interface TopLevelJsonScan { + valueSpans: Map; + duplicateKeys: Set; +} + +const skipJsonWhitespace = (raw: string, from: number): number => { + let index = from; + while (index < raw.length && /[\u0009\u000a\u000d\u0020]/.test(raw[index])) index += 1; + return index; +}; + +function scanJsonString(raw: string, start: number): number { + if (raw[start] !== '"') fail("rawJson.string"); + let index = start + 1; + while (index < raw.length) { + const character = raw[index]; + if (character === '"') return index + 1; + if (character === "\\") { + index += 2; + } else { + index += 1; + } + } + fail("rawJson.unterminatedString"); +} + +function scanJsonValue(raw: string, from: number, depth = 0): number { + if (depth > 256) fail("rawJson.depth"); + let index = skipJsonWhitespace(raw, from); + if (raw[index] === '"') return scanJsonString(raw, index); + if (raw[index] === "[") { + index = skipJsonWhitespace(raw, index + 1); + if (raw[index] === "]") return index + 1; + while (index < raw.length) { + index = skipJsonWhitespace(raw, scanJsonValue(raw, index, depth + 1)); + if (raw[index] === "]") return index + 1; + if (raw[index] !== ",") fail("rawJson.arrayDelimiter"); + index = skipJsonWhitespace(raw, index + 1); + } + fail("rawJson.unterminatedArray"); + } + if (raw[index] === "{") { + index = skipJsonWhitespace(raw, index + 1); + if (raw[index] === "}") return index + 1; + while (index < raw.length) { + const keyEnd = scanJsonString(raw, index); + index = skipJsonWhitespace(raw, keyEnd); + if (raw[index] !== ":") fail("rawJson.objectColon"); + index = skipJsonWhitespace(raw, scanJsonValue(raw, index + 1, depth + 1)); + if (raw[index] === "}") return index + 1; + if (raw[index] !== ",") fail("rawJson.objectDelimiter"); + index = skipJsonWhitespace(raw, index + 1); + } + fail("rawJson.unterminatedObject"); + } + const tokenStart = index; + while ( + index < raw.length && + !/[\u0009\u000a\u000d\u0020,\]}]/.test(raw[index]) + ) { + index += 1; + } + if (index === tokenStart) fail("rawJson.value"); + return index; +} + +function scanTopLevelJsonObject(raw: string): TopLevelJsonScan { + let index = skipJsonWhitespace(raw, 0); + if (raw[index] !== "{") fail("body"); + index = skipJsonWhitespace(raw, index + 1); + const valueSpans = new Map(); + const duplicateKeys = new Set(); + if (raw[index] === "}") { + index = skipJsonWhitespace(raw, index + 1); + if (index !== raw.length) fail("rawJson.trailingData"); + return { valueSpans, duplicateKeys }; + } + while (index < raw.length) { + const keyStart = index; + const keyEnd = scanJsonString(raw, keyStart); + const key = JSON.parse(raw.slice(keyStart, keyEnd)) as string; + index = skipJsonWhitespace(raw, keyEnd); + if (raw[index] !== ":") fail("rawJson.objectColon"); + const valueStart = skipJsonWhitespace(raw, index + 1); + const valueEnd = scanJsonValue(raw, valueStart); + if (valueSpans.has(key)) duplicateKeys.add(key); + else valueSpans.set(key, { start: valueStart, end: valueEnd }); + index = skipJsonWhitespace(raw, valueEnd); + if (raw[index] === "}") { + index = skipJsonWhitespace(raw, index + 1); + if (index !== raw.length) fail("rawJson.trailingData"); + return { valueSpans, duplicateKeys }; + } + if (raw[index] !== ",") fail("rawJson.objectDelimiter"); + index = skipJsonWhitespace(raw, index + 1); + } + fail("rawJson.unterminatedObject"); +} + +function scanJsonArrayElementSpans(raw: string, arraySpan: RawJsonSpan): RawJsonSpan[] { + let index = skipJsonWhitespace(raw, arraySpan.start); + if (raw[index] !== "[") fail("body.profilePreferenceSections"); + index = skipJsonWhitespace(raw, index + 1); + const spans: RawJsonSpan[] = []; + if (raw[index] === "]") { + if (index + 1 !== arraySpan.end) fail("body.profilePreferenceSections.span"); + return spans; + } + while (index < arraySpan.end) { + const start = index; + const end = scanJsonValue(raw, start); + spans.push({ start, end }); + index = skipJsonWhitespace(raw, end); + if (raw[index] === "]") { + if (index + 1 !== arraySpan.end) fail("body.profilePreferenceSections.span"); + return spans; + } + if (raw[index] !== ",") fail("body.profilePreferenceSections.delimiter"); + index = skipJsonWhitespace(raw, index + 1); + } + fail("body.profilePreferenceSections.span"); +} + +const sameJsonValue = (left: unknown, right: unknown): boolean => + JSON.stringify(left) === JSON.stringify(right); + +const requireBoolean = (value: unknown, field: string): boolean => { + if (typeof value !== "boolean") fail(field); + return value; +}; + +const requireFloat32 = ( + value: unknown, + field: string, + predicate: (number: number) => boolean = () => true, +): number => { + if (typeof value !== "number" || !Number.isFinite(value) || !predicate(value)) fail(field); + const narrowed = Math.fround(value); + if (!Number.isFinite(narrowed) || (value !== 0 && narrowed === 0) || !predicate(narrowed)) { + fail(field); + } + return narrowed; +}; + +const requireInt32 = ( + value: unknown, + field: string, + predicate: (number: number) => boolean = () => true, +): number => { + if ( + typeof value !== "number" || + !Number.isInteger(value) || + value < INT32_MIN || + value > INT32_MAX || + !predicate(value) + ) fail(field); + return value; +}; + +const requireSafeJsonLong = ( + value: unknown, + field: string, + predicate: (number: number) => boolean = () => true, +): number => { + if (typeof value !== "number" || !Number.isSafeInteger(value) || !predicate(value)) fail(field); + return value; +}; + +const requireNonBlank = (value: unknown, field: string): string => { + const text = requirePostgresString(value, field); + if (text.trim().length === 0) fail(field); + return text; +}; + +const requireEnum = ( + value: unknown, + allowed: readonly T[], + field: string, +): T => { + const text = requirePostgresString(value, field); + if (!allowed.includes(text as T)) fail(field); + return text as T; +}; + +const requireVersionOne = (value: unknown, field: string): 1 => { + const version = requireInt32(value, field); + if (version !== 1) fail(field, "UNSUPPORTED_DOCUMENT_VERSION"); + return 1; +}; + +const RFC3339_INSTANT = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|([+-])(\d{2}):(\d{2}))$/; + +const isLeapYear = (year: number): boolean => + year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + +const daysInMonth = (year: number, month: number): number => + [31, isLeapYear(year) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1] ?? 0; + +const requireRfc3339Instant = (value: unknown, field: string): string => { + const text = requirePostgresString(value, field); + const match = RFC3339_INSTANT.exec(text); + if (!match) fail(field); + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const hour = Number(match[4]); + const minute = Number(match[5]); + const second = Number(match[6]); + const offsetHour = match[8] === "Z" ? 0 : Number(match[10]); + const offsetMinute = match[8] === "Z" ? 0 : Number(match[11]); + if ( + year < 1 || + month < 1 || month > 12 || + day < 1 || day > daysInMonth(year, month) || + hour > 23 || minute > 59 || second > 59 || + offsetHour > 23 || offsetMinute > 59 + ) fail(field); + const epoch = Date.parse(text); + if (!Number.isFinite(epoch)) fail(field); + const normalized = new Date(epoch); + return normalized.toISOString(); +}; + +const rejectLocalOnlyKeys = (value: unknown, field = "profilePreferenceSections"): void => { + if (Array.isArray(value)) { + value.forEach((child, index) => rejectLocalOnlyKeys(child, field + "[" + index + "]")); + return; + } + if (typeof value !== "object" || value === null) return; + for (const [key, child] of Object.entries(value as JsonRecord)) { + if (LOCAL_ONLY_KEYS.has(normalizeKey(key))) fail(field + "." + key); + rejectLocalOnlyKeys(child, field + "." + key); + } +}; + +const RACK_ITEM_KEYS = [ + "id", + "name", + "category", + "weightKg", + "behavior", + "enabled", + "sortOrder", + "createdAt", + "updatedAt", +] as const; +const RACK_CATEGORIES = [ + "WEIGHTED_VEST", + "DIP_BELT", + "CHAINS", + "BAND", + "ASSISTANCE", + "ATTACHMENT", + "OTHER", +] as const; +const RACK_BEHAVIORS = [ + "ADDED_RESISTANCE", + "COUNTERWEIGHT", + "DISPLAY_ONLY", +] as const; +const WORKOUT_MODES = [0, 2, 3, 4, 6, 10] as const; +const REP_COUNT_TIMINGS = ["TOP", "BOTTOM"] as const; + +function validateCorePayload(value: unknown): JsonRecord { + const payload = requireExactRecord( + value, + ["bodyWeightKg", "weightUnit", "weightIncrement"], + "payload", + ); + requireFloat32( + payload.bodyWeightKg, + "payload.bodyWeightKg", + (number) => number === 0 || (number >= 20 && number <= 300), + ); + requireEnum(payload.weightUnit, ["KG", "LB"] as const, "payload.weightUnit"); + requireFloat32( + payload.weightIncrement, + "payload.weightIncrement", + (number) => number === -1 || number > 0, + ); + return payload; +} + +function validateRackPayload(value: unknown): JsonRecord { + const payload = requireExactRecord(value, ["version", "items"], "payload"); + requireVersionOne(payload.version, "payload.version"); + const ids = new Set(); + requireArray(payload.items, "payload.items").forEach((rawItem, index) => { + const field = "payload.items[" + index + "]"; + const item = requireExactRecord(rawItem, RACK_ITEM_KEYS, field); + const id = requireNonBlank(item.id, field + ".id"); + requireNonBlank(item.name, field + ".name"); + if (ids.has(id)) fail(field + ".id"); + ids.add(id); + requireEnum(item.category, RACK_CATEGORIES, field + ".category"); + requireFloat32(item.weightKg, field + ".weightKg", (number) => number >= 0); + requireEnum(item.behavior, RACK_BEHAVIORS, field + ".behavior"); + requireBoolean(item.enabled, field + ".enabled"); + requireInt32(item.sortOrder, field + ".sortOrder"); + requireSafeJsonLong(item.createdAt, field + ".createdAt"); + requireSafeJsonLong(item.updatedAt, field + ".updatedAt"); + }); + return payload; +} + +const JUST_LIFT_KEYS = [ + "workoutModeId", + "weightPerCableKg", + "weightChangePerRep", + "eccentricLoadPercentage", + "echoLevelValue", + "stallDetectionEnabled", + "repCountTimingName", + "restSeconds", +] as const; + +const SINGLE_EXERCISE_KEYS = [ + "exerciseId", + "setReps", + "weightPerCableKg", + "setWeightsPerCableKg", + "progressionKg", + "setRestSeconds", + "workoutModeId", + "eccentricLoadPercentage", + "echoLevelValue", + "duration", + "isAMRAP", + "perSetRestTime", + "defaultRackItemIds", +] as const; + +const WORKOUT_KEYS = [ + "version", + "stopAtTop", + "beepsEnabled", + "stallDetectionEnabled", + "audioRepCountEnabled", + "repCountTiming", + "summaryCountdownSeconds", + "autoStartCountdownSeconds", + "gamificationEnabled", + "autoStartRoutine", + "countdownBeepsEnabled", + "repSoundEnabled", + "motionStartEnabled", + "weightSuggestionsEnabled", + "defaultRoutineExerciseUsePercentOfPR", + "defaultRoutineExerciseWeightPercentOfPR", + "voiceStopEnabled", + "justLiftDefaults", + "singleExerciseDefaults", +] as const; + +function validateJustLiftDefaults(value: unknown, field: string): void { + const defaults = requireExactRecord(value, JUST_LIFT_KEYS, field); + requireInt32( + defaults.workoutModeId, + field + ".workoutModeId", + (number) => WORKOUT_MODES.includes(number as typeof WORKOUT_MODES[number]), + ); + requireFloat32(defaults.weightPerCableKg, field + ".weightPerCableKg", (number) => number >= 0); + requireFloat32(defaults.weightChangePerRep, field + ".weightChangePerRep"); + requireInt32( + defaults.eccentricLoadPercentage, + field + ".eccentricLoadPercentage", + (number) => number >= 0 && number <= 150, + ); + requireInt32( + defaults.echoLevelValue, + field + ".echoLevelValue", + (number) => number >= 0 && number <= 3, + ); + requireBoolean(defaults.stallDetectionEnabled, field + ".stallDetectionEnabled"); + requireEnum(defaults.repCountTimingName, REP_COUNT_TIMINGS, field + ".repCountTimingName"); + requireInt32( + defaults.restSeconds, + field + ".restSeconds", + (number) => number === 0 || (number >= 5 && number <= 300), + ); +} + +function validateSingleExerciseDefaults( + mapKey: string, + value: unknown, + field: string, +): void { + const defaults = requireExactRecord(value, SINGLE_EXERCISE_KEYS, field); + const exerciseId = requireNonBlank(defaults.exerciseId, field + ".exerciseId"); + if (mapKey.trim().length === 0 || exerciseId !== mapKey) fail(field + ".exerciseId"); + requireArray(defaults.setReps, field + ".setReps").forEach((rep, index) => { + if (rep !== null) { + requireInt32(rep, field + ".setReps[" + index + "]", (number) => number >= 0); + } + }); + requireFloat32(defaults.weightPerCableKg, field + ".weightPerCableKg", (number) => number >= 0); + requireArray(defaults.setWeightsPerCableKg, field + ".setWeightsPerCableKg") + .forEach((weight, index) => requireFloat32( + weight, + field + ".setWeightsPerCableKg[" + index + "]", + (number) => number >= 0, + )); + requireFloat32(defaults.progressionKg, field + ".progressionKg"); + requireArray(defaults.setRestSeconds, field + ".setRestSeconds") + .forEach((rest, index) => requireInt32( + rest, + field + ".setRestSeconds[" + index + "]", + (number) => number === 0 || (number >= 5 && number <= 300), + )); + requireInt32( + defaults.workoutModeId, + field + ".workoutModeId", + (number) => WORKOUT_MODES.includes(number as typeof WORKOUT_MODES[number]), + ); + requireInt32( + defaults.eccentricLoadPercentage, + field + ".eccentricLoadPercentage", + (number) => number >= 0 && number <= 150, + ); + requireInt32( + defaults.echoLevelValue, + field + ".echoLevelValue", + (number) => number >= 0 && number <= 3, + ); + requireInt32(defaults.duration, field + ".duration", (number) => number >= 0); + requireBoolean(defaults.isAMRAP, field + ".isAMRAP"); + requireBoolean(defaults.perSetRestTime, field + ".perSetRestTime"); + const rackIds = requireArray(defaults.defaultRackItemIds, field + ".defaultRackItemIds") + .map((rackId, index) => requireNonBlank( + rackId, + field + ".defaultRackItemIds[" + index + "]", + )); + if (new Set(rackIds).size !== rackIds.length) fail(field + ".defaultRackItemIds"); +} + +function validateWorkoutPayload(value: unknown): JsonRecord { + const payload = requireExactRecord(value, WORKOUT_KEYS, "payload"); + requireVersionOne(payload.version, "payload.version"); + [ + "stopAtTop", + "beepsEnabled", + "stallDetectionEnabled", + "audioRepCountEnabled", + "gamificationEnabled", + "autoStartRoutine", + "countdownBeepsEnabled", + "repSoundEnabled", + "motionStartEnabled", + "weightSuggestionsEnabled", + "defaultRoutineExerciseUsePercentOfPR", + "voiceStopEnabled", + ].forEach((key) => requireBoolean(payload[key], "payload." + key)); + requireEnum(payload.repCountTiming, REP_COUNT_TIMINGS, "payload.repCountTiming"); + requireInt32( + payload.summaryCountdownSeconds, + "payload.summaryCountdownSeconds", + (number) => [-1, 0, 5, 10, 15, 20, 25, 30].includes(number), + ); + requireInt32( + payload.autoStartCountdownSeconds, + "payload.autoStartCountdownSeconds", + (number) => number >= 2 && number <= 10, + ); + requireInt32( + payload.defaultRoutineExerciseWeightPercentOfPR, + "payload.defaultRoutineExerciseWeightPercentOfPR", + (number) => number >= 50 && number <= 120, + ); + validateJustLiftDefaults(payload.justLiftDefaults, "payload.justLiftDefaults"); + const singleExerciseDefaults = requireRecord( + payload.singleExerciseDefaults, + "payload.singleExerciseDefaults", + ); + Object.entries(singleExerciseDefaults).forEach(([key, defaults]) => + validateSingleExerciseDefaults( + key, + defaults, + "payload.singleExerciseDefaults." + key, + ) + ); + return payload; +} + +function validateLedPayload(value: unknown): JsonRecord { + const payload = requireExactRecord( + value, + ["ledColorSchemeId", "preferences"], + "payload", + ); + requireInt32( + payload.ledColorSchemeId, + "payload.ledColorSchemeId", + (number) => number >= 0, + ); + const preferences = requireExactRecord( + payload.preferences, + ["version", "discoModeUnlocked"], + "payload.preferences", + ); + requireVersionOne(preferences.version, "payload.preferences.version"); + requireBoolean(preferences.discoModeUnlocked, "payload.preferences.discoModeUnlocked"); + return payload; +} + +function validateVbtPayload(value: unknown): JsonRecord { + const payload = requireExactRecord(value, ["vbtEnabled", "preferences"], "payload"); + requireBoolean(payload.vbtEnabled, "payload.vbtEnabled"); + const preferences = requireExactRecord( + payload.preferences, + [ + "version", + "velocityLossThresholdPercent", + "autoEndOnVelocityLoss", + "defaultScalingBasis", + "verbalEncouragementEnabled", + "vulgarModeEnabled", + "vulgarTier", + "dominatrixModeUnlocked", + "dominatrixModeActive", + ], + "payload.preferences", + ); + requireVersionOne(preferences.version, "payload.preferences.version"); + requireInt32( + preferences.velocityLossThresholdPercent, + "payload.preferences.velocityLossThresholdPercent", + (number) => number >= 10 && number <= 50, + ); + requireBoolean(preferences.autoEndOnVelocityLoss, "payload.preferences.autoEndOnVelocityLoss"); + requireEnum( + preferences.defaultScalingBasis, + ["MAX_WEIGHT_PR", "MAX_VOLUME_PR", "ESTIMATED_1RM"] as const, + "payload.preferences.defaultScalingBasis", + ); + requireBoolean( + preferences.verbalEncouragementEnabled, + "payload.preferences.verbalEncouragementEnabled", + ); + requireBoolean(preferences.vulgarModeEnabled, "payload.preferences.vulgarModeEnabled"); + requireEnum( + preferences.vulgarTier, + ["MILD", "STRONG", "MIX"] as const, + "payload.preferences.vulgarTier", + ); + requireBoolean( + preferences.dominatrixModeUnlocked, + "payload.preferences.dominatrixModeUnlocked", + ); + requireBoolean( + preferences.dominatrixModeActive, + "payload.preferences.dominatrixModeActive", + ); + return payload; +} + +function parsePreferenceMutation(value: unknown): PortalProfilePreferenceSectionMutation { + requirePostgresTextTree(value, "mutation"); + rejectLocalOnlyKeys(value); + const mutation = requireExactRecord(value, MUTATION_KEYS, "mutation"); + const localProfileId = requireNonBlank(mutation.localProfileId, "mutation.localProfileId"); + if (typeof mutation.section !== "string") fail("mutation.section", "UNSUPPORTED_SECTION"); + if (!["CORE", "RACK", "WORKOUT", "LED", "VBT"].includes(mutation.section)) { + fail("mutation.section", "UNSUPPORTED_SECTION"); + } + const section = mutation.section as ProfilePreferenceSection; + const documentVersion = requireVersionOne( + mutation.documentVersion, + "mutation.documentVersion", + ); + const baseRevision = requireSafeJsonLong( + mutation.baseRevision, + "mutation.baseRevision", + (number) => number >= 0, + ); + const clientModifiedAt = requireRfc3339Instant( + mutation.clientModifiedAt, + "mutation.clientModifiedAt", + ); + const payload = ({ + CORE: validateCorePayload, + RACK: validateRackPayload, + WORKOUT: validateWorkoutPayload, + LED: validateLedPayload, + VBT: validateVbtPayload, + } as const)[section](mutation.payload); + return { + localProfileId, + section, + documentVersion, + baseRevision, + clientModifiedAt, + payload, + }; +} + +interface PreferenceEnvelope { + present: boolean; + validatedMutations: PortalProfilePreferenceSectionMutation[]; + rejections: ProfilePreferenceSectionRejection[]; +} + +interface PreferenceRawContext { + rawBody: string; + preferenceElementSpans: RawJsonSpan[]; +} + +const rawPreferenceIdentity = (value: unknown): string | null => { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + const record = value as JsonRecord; + if (typeof record.localProfileId !== "string" || typeof record.section !== "string") { + return null; + } + return JSON.stringify([record.localProfileId, record.section]); +}; + +const rawPreferenceLabel = (value: unknown): { localProfileId: string; section: string } => { + const record = + typeof value === "object" && value !== null && !Array.isArray(value) + ? value as JsonRecord + : {}; + return { + localProfileId: typeof record.localProfileId === "string" ? record.localProfileId : "", + section: typeof record.section === "string" ? record.section : "UNKNOWN", + }; +}; + +function parsePreferenceEnvelope( + body: JsonRecord, + rawContext: PreferenceRawContext, +): PreferenceEnvelope { + requireKnownKeys(body, PUSH_BODY_KEYS, "body"); + if (!Object.hasOwn(body, "profilePreferenceSections")) { + if (rawContext.preferenceElementSpans.length !== 0) { + fail("body.profilePreferenceSections.span"); + } + return { present: false, validatedMutations: [], rejections: [] }; + } + const rawMutations = requireArray( + body.profilePreferenceSections, + "body.profilePreferenceSections", + ); + if (rawMutations.length !== rawContext.preferenceElementSpans.length) { + fail("body.profilePreferenceSections.span"); + } + rawMutations.forEach((rawMutation, index) => { + const span = rawContext.preferenceElementSpans[index]; + let reparsed: unknown; + try { + reparsed = JSON.parse(rawContext.rawBody.slice(span.start, span.end)); + } catch { + fail("body.profilePreferenceSections.span"); + } + if (!sameJsonValue(reparsed, rawMutation)) { + fail("body.profilePreferenceSections.span"); + } + }); + + // Raw-span structural/reparse failures above are envelope-level 400 errors. From this point, + // duplicate, size, and schema/Unicode outcomes are isolated to their section identities. + const identityCounts = new Map(); + rawMutations.forEach((rawMutation) => { + const identity = rawPreferenceIdentity(rawMutation); + if (identity !== null) { + identityCounts.set(identity, (identityCounts.get(identity) ?? 0) + 1); + } + }); + const duplicateIdentities = new Set( + [...identityCounts.entries()] + .filter(([, count]) => count > 1) + .map(([identity]) => identity), + ); + + const validatedMutations: PortalProfilePreferenceSectionMutation[] = []; + const rejections: ProfilePreferenceSectionRejection[] = []; + const duplicateReported = new Set(); + rawMutations.forEach((rawMutation, index) => { + const label = rawPreferenceLabel(rawMutation); + const identity = rawPreferenceIdentity(rawMutation); + if (identity !== null && duplicateIdentities.has(identity)) { + if (!duplicateReported.has(identity)) { + duplicateReported.add(identity); + rejections.push({ + ...label, + serverRevision: 0, + reason: "DUPLICATE_SECTION", + }); + } + return; + } + const span = rawContext.preferenceElementSpans[index]; + const rawPreferenceElementBytes = utf8Bytes( + rawContext.rawBody.slice(span.start, span.end), + ); + if (rawPreferenceElementBytes > MAX_PROFILE_PREFERENCE_SECTION_BYTES) { + rejections.push({ + ...label, + serverRevision: 0, + reason: "SECTION_TOO_LARGE", + }); + return; + } + try { + const mutation = parsePreferenceMutation(rawMutation); + validatedMutations.push(mutation); + } catch (error) { + if (!(error instanceof PreferenceValidationError)) throw error; + rejections.push({ + ...label, + serverRevision: 0, + reason: error.reason, + }); + } + }); + return { present: true, validatedMutations, rejections }; +} +``` + +Authenticate with an anon client, parse and validate the complete body, and only then construct or call the privileged client. `validateExistingMobileSyncPushBody` below means the existing strict, side-effect-free validator for every non-preference field in `PUSH_BODY_KEYS`; it must neither recurse into nor revalidate `profilePreferenceSections`, because `parsePreferenceMutation` owns that field's per-section Unicode/schema rejection boundary. Wire the ordinary validator to the endpoint's real parser and add a regression proving a malformed final ordinary or preference item causes zero admin table/RPC calls: + +```typescript +const authorization = req.headers.get("Authorization"); +const bearerMatch = authorization === null + ? null + : /^Bearer ([^\s]+)$/.exec(authorization); +if (!bearerMatch) { + return new Response(JSON.stringify({ error: "Missing bearer token" }), { status: 401 }); +} +const userJwt = bearerMatch[1]; +const authClient = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { + global: { headers: { Authorization: authorization } }, + auth: { persistSession: false, autoRefreshToken: false }, +}); + +const safeErrorName = (error: unknown, fallback: string): string => { + let candidate = fallback; + if (error instanceof Error) { + candidate = error.name; + } else if ( + typeof error === "object" && error !== null && + typeof (error as JsonRecord).name === "string" + ) { + candidate = (error as JsonRecord).name as string; + } + return /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/.test(candidate) ? candidate : fallback; +}; + +const authOperationalFailure = (error: unknown): Response => { + console.error({ name: safeErrorName(error, "AuthOperationalFailure") }); + return new Response( + JSON.stringify({ error: "Authentication service unavailable" }), + { status: 503 }, + ); +}; + +const returnedAuthStatus = (error: unknown): number | null => { + if (typeof error !== "object" || error === null) return null; + const record = error as JsonRecord; + const status = record.status ?? record.statusCode; + return typeof status === "number" && Number.isInteger(status) ? status : null; +}; + +let authResult: unknown; +try { + authResult = await authClient.auth.getUser(userJwt); +} catch (error) { + return authOperationalFailure(error); +} +if (typeof authResult !== "object" || authResult === null || Array.isArray(authResult)) { + return authOperationalFailure({ name: "AuthUnexpectedResult" }); +} +const authRecord = authResult as JsonRecord; +if (!Object.hasOwn(authRecord, "error") || !Object.hasOwn(authRecord, "data")) { + return authOperationalFailure({ name: "AuthUnexpectedResult" }); +} +const userError = authRecord.error; +if (userError !== null) { + const status = returnedAuthStatus(userError); + if (status === 400 || status === 401 || status === 403) { + return new Response(JSON.stringify({ error: "Invalid bearer token" }), { status: 401 }); + } + return authOperationalFailure(userError); +} +const userData = authRecord.data; +if (typeof userData !== "object" || userData === null || Array.isArray(userData)) { + return authOperationalFailure({ name: "AuthUnexpectedResult" }); +} +const verifiedUser = (userData as JsonRecord).user; +if ( + typeof verifiedUser !== "object" || verifiedUser === null || Array.isArray(verifiedUser) || + typeof (verifiedUser as JsonRecord).id !== "string" || + ((verifiedUser as JsonRecord).id as string).length === 0 +) { + return authOperationalFailure({ name: "AuthUnexpectedResult" }); +} +const verifiedUserId = (verifiedUser as JsonRecord).id as string; + +let originalBodyBytes: Uint8Array; +try { + originalBodyBytes = new Uint8Array(await req.arrayBuffer()); +} catch (error) { + console.error({ name: safeErrorName(error, "RequestBodyReadFailure") }); + return new Response(JSON.stringify({ error: "Request unavailable" }), { status: 503 }); +} +const rawBodyBytes = originalBodyBytes.byteLength; +if (rawBodyBytes > MAX_MOBILE_SYNC_REQUEST_BYTES) { + return new Response(JSON.stringify({ error: "Request too large" }), { status: 413 }); +} +const hasLeadingUtf8Bom = + originalBodyBytes.length >= 3 && + originalBodyBytes[0] === 0xef && + originalBodyBytes[1] === 0xbb && + originalBodyBytes[2] === 0xbf; +if (hasLeadingUtf8Bom) { + return new Response(JSON.stringify({ error: "Invalid sync request" }), { status: 400 }); +} +let rawBody: string; +try { + rawBody = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }) + .decode(originalBodyBytes); +} catch { + return new Response(JSON.stringify({ error: "Invalid sync request" }), { status: 400 }); +} +if (rawBody.startsWith("\uFEFF")) { + return new Response(JSON.stringify({ error: "Invalid sync request" }), { status: 400 }); +} + +let topLevelScan: TopLevelJsonScan; +try { + topLevelScan = scanTopLevelJsonObject(rawBody); + for (const duplicateKey of topLevelScan.duplicateKeys) { + if (PUSH_BODY_KEYS.has(duplicateKey)) fail("body." + duplicateKey); + } +} catch (error) { + if (!(error instanceof PreferenceValidationError) && !(error instanceof SyntaxError)) { + throw error; + } + return new Response(JSON.stringify({ error: "Invalid sync request" }), { status: 400 }); +} +const preferenceValueSpan = topLevelScan.valueSpans.get("profilePreferenceSections"); +if ( + preferenceValueSpan !== undefined && + rawBodyBytes > MAX_PROFILE_PREFERENCE_REQUEST_BYTES +) { + return new Response(JSON.stringify({ error: "Request too large" }), { status: 413 }); +} + +let body: JsonRecord; +let preferenceEnvelope: PreferenceEnvelope; +try { + const preferenceElementSpans = preferenceValueSpan === undefined + ? [] + : scanJsonArrayElementSpans(rawBody, preferenceValueSpan); + const parsedBody = JSON.parse(rawBody) as unknown; + body = requireRecord(parsedBody, "body"); + preferenceEnvelope = parsePreferenceEnvelope(body, { + rawBody, + preferenceElementSpans, + }); + validateExistingMobileSyncPushBody(body); +} catch (error) { + if (!(error instanceof PreferenceValidationError) && !(error instanceof SyntaxError)) { + throw error; + } + return new Response(JSON.stringify({ error: "Invalid sync request" }), { status: 400 }); +} + +const admin = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, { + auth: { persistSession: false, autoRefreshToken: false }, +}); +``` + +The 9,500,000-byte cap counts `originalBodyBytes.byteLength` only when `profilePreferenceSections` is absent. Whenever that top-level field is present, the 524,288-byte cap applies to that same complete original byte sequence, including all whitespace, ordinary fields, and preference fields. Reject oversize requests from the original count, never from a decoded/re-encoded surrogate. The 262,144-byte cap applies to each exact raw JSON array-element span measured by the quote-, escape-, and nesting-aware scanner. Only after one fatal valid-UTF-8 decode and explicit no-BOM check is `TextEncoder.encode(rawBody.slice(span.start, span.end))` byte-identical to the corresponding original octets: valid UTF-8 has a unique encoding and JSON structural offsets fall on scalar boundaries. It is never reconstructed with `JSON.stringify`. Reject malformed UTF-8, a leading BOM/U+FEFF, a duplicate relevant top-level key, element-count mismatch, or reparsed-span/value mismatch before constructing the admin client. Exact-size payloads are accepted; 262,145 and 524,289 bytes are rejected. `clientModifiedAt` is strict timezone-bearing RFC3339 audit metadata normalized by the shared instant helper and is never used for ordering. + +Treat only a successfully parsed RPC row as a domain result. Use the exact runtime parser and loop below so empty/multiple/malformed rows, unknown rejection reasons, permission failures, and PostgREST errors are infrastructure failures rather than fabricated `VALIDATION_FAILED` results: + +```typescript +interface RpcMutationRow { + accepted: boolean; + rejection_reason: string | null; + server_revision: number | string; + canonical_section: unknown | null; +} + +const RPC_DOMAIN_REASONS = new Set([ + "REVISION_CONFLICT", + "VALIDATION_FAILED", + "UNSUPPORTED_SECTION", + "UNSUPPORTED_DOCUMENT_VERSION", + "UNKNOWN_PROFILE", +]); + +const infrastructureRevision = (value: unknown): number => { + const number = typeof value === "string" && /^[0-9]+$/.test(value) + ? Number(value) + : value; + if (typeof number !== "number" || !Number.isSafeInteger(number) || number < 0) { + throw new PreferenceInfrastructureError("malformed revision"); + } + return number; +}; + +function parseInfrastructureCanonical( + value: unknown, + mutation: PortalProfilePreferenceSectionMutation, +): PortalProfilePreferenceSectionCanonical { + try { + requirePostgresTextTree(value, "canonical"); + const canonical = requireExactRecord( + value, + [ + "localProfileId", + "section", + "documentVersion", + "serverRevision", + "serverUpdatedAt", + "payload", + ], + "canonical", + ); + if (canonical.localProfileId !== mutation.localProfileId) fail("canonical.localProfileId"); + if (canonical.section !== mutation.section) fail("canonical.section"); + requireVersionOne(canonical.documentVersion, "canonical.documentVersion"); + const serverRevision = infrastructureRevision(canonical.serverRevision); + const serverUpdatedAt = requireRfc3339Instant( + canonical.serverUpdatedAt, + "canonical.serverUpdatedAt", + ); + const payload = ({ + CORE: validateCorePayload, + RACK: validateRackPayload, + WORKOUT: validateWorkoutPayload, + LED: validateLedPayload, + VBT: validateVbtPayload, + } as const)[mutation.section](canonical.payload); + return { + localProfileId: mutation.localProfileId, + section: mutation.section, + documentVersion: 1, + serverRevision, + serverUpdatedAt, + payload, + }; + } catch (error) { + if (error instanceof PreferenceInfrastructureError) throw error; + throw new PreferenceInfrastructureError("malformed canonical"); + } +} + +function parseRpcMutationRow( + data: unknown, + mutation: PortalProfilePreferenceSectionMutation, +): { + accepted: boolean; + rejectionReason: string | null; + serverRevision: number; + canonicalSection?: PortalProfilePreferenceSectionCanonical; +} { + if (!Array.isArray(data) || data.length !== 1) { + throw new PreferenceInfrastructureError("RPC row cardinality"); + } + try { + const row = requireExactRecord( + data[0], + ["accepted", "rejection_reason", "server_revision", "canonical_section"], + "rpcRow", + ) as unknown as RpcMutationRow; + if (typeof row.accepted !== "boolean") fail("rpcRow.accepted"); + const serverRevision = infrastructureRevision(row.server_revision); + const canonicalSection = row.canonical_section === null + ? undefined + : parseInfrastructureCanonical(row.canonical_section, mutation); + if (row.accepted) { + if (row.rejection_reason !== null || !canonicalSection) fail("rpcRow"); + } else { + if ( + typeof row.rejection_reason !== "string" || + !RPC_DOMAIN_REASONS.has(row.rejection_reason) + ) { + fail("rpcRow.rejection_reason"); + } + if (row.rejection_reason === "REVISION_CONFLICT" && !canonicalSection) { + fail("rpcRow.canonical_section"); + } + } + if (canonicalSection && canonicalSection.serverRevision !== serverRevision) fail("rpcRow"); + return { + accepted: row.accepted, + rejectionReason: row.rejection_reason, + serverRevision, + canonicalSection, + }; + } catch (error) { + if (error instanceof PreferenceInfrastructureError) throw error; + throw new PreferenceInfrastructureError("malformed RPC row"); + } +} + +const canonicalProfilePreferenceSections: PortalProfilePreferenceSectionCanonical[] = []; +const profilePreferenceRejections: ProfilePreferenceSectionRejection[] = [ + ...preferenceEnvelope.rejections, +]; + +try { + for (const mutation of preferenceEnvelope.validatedMutations) { + const { data, error } = await admin.rpc("mutate_local_profile_preference_section", { + p_user_id: verifiedUserId, + p_local_profile_id: mutation.localProfileId, + p_section: mutation.section, + p_document_version: mutation.documentVersion, + p_base_revision: mutation.baseRevision, + p_payload: mutation.payload, + }); + if (error) throw new PreferenceInfrastructureError("mutation RPC"); + const result = parseRpcMutationRow(data, mutation); + if (result.accepted) { + canonicalProfilePreferenceSections.push(result.canonicalSection!); + } else { + profilePreferenceRejections.push({ + localProfileId: mutation.localProfileId, + section: mutation.section, + serverRevision: result.serverRevision, + reason: result.rejectionReason as ProfilePreferenceSectionRejection["reason"], + ...(result.canonicalSection + ? { canonicalSection: result.canonicalSection } + : {}), + }); + } + } +} catch (error) { + console.error({ name: safeErrorName(error, "PreferenceInfrastructureFailure") }); + return new Response(JSON.stringify({ error: "Sync temporarily unavailable" }), { + status: 503, + }); +} + +const preferenceResponseAdditions = { + ...(preferenceEnvelope.present ? { profilePreferencesAccepted: true } : {}), + canonicalProfilePreferenceSections, + profilePreferenceRejections, +}; +``` + +The document must state these response and pull rules in executable terms: + +- `profilePreferencesAccepted` is emitted as `true` only when `profilePreferenceSections` was present, envelope-validated, and evaluated. It is omitted for legacy callers. +- Every accepted RPC row contributes its canonical object; every well-formed domain rejection contributes its reason and canonical object when supplied. Local validation rejections coexist with valid siblings. +- Any unexpected RPC/query/permission/transport error aborts the HTTP response with a sanitized 5xx. It is never converted to `VALIDATION_FAILED`, and no payload, JWT, service-role secret, profile id, PostgREST message, or raw error message is logged. +- Push and pull merge these additions into the existing response without removing or changing the required `syncTime` field. +- Keep `verify_jwt = true` for both functions in `supabase/config.toml`. +- The Edge function performs no network call while a database row lock is held; one RPC invocation owns one complete Postgres transaction. +- Same-section concurrent first writes produce exactly one revision-1 acceptance and one canonical revision conflict. Different-section concurrent first writes against the same profile each produce revision 1 and preserve the sibling section. + +Lost acknowledgement is an expected convergence path, not an idempotency-key feature. If mutation A commits and a later sibling B experiences an infrastructure failure, the endpoint returns 5xx and acknowledges neither. Mobile retains both dirty generations. Retrying A with its old `baseRevision` returns `REVISION_CONFLICT` plus A's committed canonical revision without incrementing it a second time; the mobile generation ledger applies that canonical state and converges. B then retries normally. Do not trust `deviceId`, `clientModifiedAt`, or a client-generated idempotency value as mutation ordering. + +```toml +[functions.mobile-sync-push] +verify_jwt = true + +[functions.mobile-sync-pull] +verify_jwt = true +``` + +In `mobile-sync-pull/index.ts`, reuse the exact bearer-token/`auth.getUser(userJwt)` classifier above: only returned Auth errors with 400/401/403 become 401; 429/5xx/other or missing status, malformed results, and thrown/rejected calls become name-only-logged generic 503 responses. Derive `verifiedUserId` only from the verified user, and construct the service-role client only after successful authentication and strict pull-request validation. Use an explicit verified-owner predicate, then map the typed columns/documents into the same canonical wrappers returned by the mutation RPC: + +```typescript +const canonical = ( + localProfileId: string, + section: ProfilePreferenceSection, + serverRevision: number, + serverUpdatedAt: string, + payload: JsonRecord, +): PortalProfilePreferenceSectionCanonical => ({ + localProfileId, + section, + documentVersion: 1, + serverRevision, + serverUpdatedAt, + payload, +}); + +const canonicalTimestamp = (value: unknown): string => + requireRfc3339Instant(value, "pull.serverUpdatedAt"); + +async function loadFirstPageProfilePreferences( + cursor: string | null | undefined, + requestedProfileId: string | null | undefined, +): Promise { + if (cursor || !requestedProfileId || requestedProfileId.trim().length === 0) { + return undefined; + } + const { data: preferenceRow, error: preferenceError } = await admin + .from("local_profile_preferences") + .select( + "local_profile_id,body_weight_kg,weight_unit,weight_increment," + + "core_revision,core_updated_at,equipment_rack,rack_revision,rack_updated_at," + + "workout_preferences,workout_revision,workout_updated_at," + + "led_color_scheme_id,led_preferences,led_revision,led_updated_at," + + "vbt_enabled,vbt_preferences,vbt_revision,vbt_updated_at", + ) + .eq("user_id", verifiedUserId) + .eq("local_profile_id", requestedProfileId) + .maybeSingle(); + if (preferenceError) throw new PreferenceInfrastructureError("preference pull"); + if (!preferenceRow) return undefined; + + try { + const core = validateCorePayload({ + bodyWeightKg: preferenceRow.body_weight_kg, + weightUnit: preferenceRow.weight_unit, + weightIncrement: preferenceRow.weight_increment, + }); + const rack = validateRackPayload(preferenceRow.equipment_rack); + const workout = validateWorkoutPayload(preferenceRow.workout_preferences); + const led = validateLedPayload({ + ledColorSchemeId: preferenceRow.led_color_scheme_id, + preferences: preferenceRow.led_preferences, + }); + const vbt = validateVbtPayload({ + vbtEnabled: preferenceRow.vbt_enabled, + preferences: preferenceRow.vbt_preferences, + }); + return [ + canonical( + requestedProfileId, + "CORE", + infrastructureRevision(preferenceRow.core_revision), + canonicalTimestamp(preferenceRow.core_updated_at), + core, + ), + canonical( + requestedProfileId, + "RACK", + infrastructureRevision(preferenceRow.rack_revision), + canonicalTimestamp(preferenceRow.rack_updated_at), + rack, + ), + canonical( + requestedProfileId, + "WORKOUT", + infrastructureRevision(preferenceRow.workout_revision), + canonicalTimestamp(preferenceRow.workout_updated_at), + workout, + ), + canonical( + requestedProfileId, + "LED", + infrastructureRevision(preferenceRow.led_revision), + canonicalTimestamp(preferenceRow.led_updated_at), + led, + ), + canonical( + requestedProfileId, + "VBT", + infrastructureRevision(preferenceRow.vbt_revision), + canonicalTimestamp(preferenceRow.vbt_updated_at), + vbt, + ), + ]; + } catch (error) { + if (error instanceof PreferenceInfrastructureError) throw error; + throw new PreferenceInfrastructureError("malformed preference pull row"); + } +} + +const profilePreferenceSections = await loadFirstPageProfilePreferences( + cursor, + requestedProfileId, +); +``` + +Only the first page (`!cursor`) for a nonblank requested profile may execute this query. Later pages omit the field but keep normal pagination and `syncTime`. An absent preference row also omits the field and never creates a row. The containing pull handler catches `PreferenceInfrastructureError`, logs only its sanitized error class name, and returns a generic 5xx. + +Add this machine-readable manifest exactly; the mobile handoff contract test parses only this fenced block, so prose cannot impersonate test coverage: + +```portal-test-manifest +database:exact-function-acls-and-no-client-dml +database:temporary-grant-owner-rls-and-cross-owner-user-id-protection +database:base-revision-accept-and-stale-canonical-conflict +edge:auth-and-cross-user-profile-rejection +edge:auth-rejection-vs-operational-outage-classification +edge:strict-five-section-validation-and-local-only-rejection +edge:kotlin-int32-float32-unicode-and-rfc3339-parity +edge:fatal-utf8-bom-and-original-byte-enforcement +edge:section-262143-262144-262145-byte-boundaries +edge:envelope-524287-524288-524289-byte-boundaries +edge:unexpected-rpc-error-is-sanitized-5xx +edge:same-section-concurrent-first-write +edge:different-section-concurrent-first-write +edge:lost-ack-retry-canonical-convergence +edge:mutation-and-first-page-pull-canonical-equality +edge:later-pull-pages-omit-preferences-and-keep-sync-time +``` + +Implement the manifest with real database and function tests, not string searches: + +- In `supabase/tests/database/profile_preferences.test.sql`, use pgTAP in a transaction. Assert the two exact function identities/signatures, `SECURITY INVOKER`, empty `search_path`, owner, volatility, return shapes, and execute ACLs; assert `PUBLIC`, `anon`, and `authenticated` have neither function execute nor table DML while `service_role` has only the intended table/function privileges. +- In that pgTAP file, temporarily grant table DML inside the test transaction solely to exercise RLS. Set authenticated JWT claims for owner A and owner B, prove each CRUD operation sees/mutates only its own composite parent, and prove owner A cannot update a row's `user_id` to owner B because the `WITH CHECK` predicate fails. Do not claim that RLS makes a same-owner `local_profile_id` immutable; no trigger is part of this plan. Revoke the temporary grants and verify the production ACLs again before rollback. +- Seed two composite profile keys and call the real mutation function for all five sections. Prove base revision 0 accepts revision 1, the next matching base accepts exactly the next revision, a stale base returns `REVISION_CONFLICT` plus byte/equality-identical canonical JSON, and mutating one section leaves all four sibling payloads/revisions unchanged. For every call, prove the targeted row keeps its `user_id` and `local_profile_id`, the non-target key is untouched, and the RPC cannot mutate either key. Exercise explicit `UNSUPPORTED_SECTION`, `UNSUPPORTED_DOCUMENT_VERSION`, `VALIDATION_FAILED`, and `UNKNOWN_PROFILE` rows. +- In `mobile-sync-push/index.test.ts`, invoke the exported handler with injected anon/admin clients and two real local Supabase users. Cover missing headers plus blank, whitespace-bearing, multi-token, and otherwise malformed `Bearer` suffixes as immediate HTTP 401 responses with zero `getUser` or admin calls. Cover returned Auth errors with each of 400, 401, and 403 as HTTP 401. Separately inject returned 429, 500, and 503 errors, an error with no status, a null/array/malformed result, missing `error` or `data` discriminants, a non-null error without status, a success without a user, and thrown/rejected `getUser` calls; each is a generic 503 whose captured console call has exactly one argument equal to an object with exactly the `name` key. Every auth failure constructs/calls zero admin clients. Also cover attempted cross-user profile mutation and the absence of any request-body `userId` authority. Spy on every privileged table/RPC method and prove malformed data in the final ordinary or preference item produces zero privileged calls. +- Table-drive every required and unknown key, primitive type, enum, range, nested object, duplicate rack id, duplicate `(localProfileId, section)`, all five version-1 wrappers, every unsupported wrapper/embedded version, and recursive normalized local-only names. For Kotlin `Int`, prove rack `sortOrder` accepts exactly -2147483648 and 2147483647 and rejects either adjacent overflow; cover workout `setReps`/`duration` and LED color scheme with both Int32 and their narrower business rules. For Kotlin `Float`, prove `Float.MAX_VALUE` and the smallest nonzero Float32 survive where business rules allow, while positive/negative Float32 overflow and nonzero underflow-to-zero are rejected; apply every business predicate to both the original JavaScript number and its `Math.fround` result. In CORE, accept exact `bodyWeightKg` values `20` and `300`, but reject exact adjacent inputs `19.9999999` and `300.00001` even though Float32 rounding produces `20` and `300`. For the nonnegative RACK `weightKg` JSONB field, accept `0` and the smallest positive Float32, but reject exact `-1e-46` rather than allowing its `-0` Float32 result through. Safe JSON integers apply only to Long revisions/timestamps. Recursively test raw and escaped U+0000 plus lone high/low surrogates in nested string values and `singleExerciseDefaults`/other object keys; each is rejected pre-admin, while a valid supplementary pair/emoji passes. Put one such Unicode-invalid unique-key section beside a valid unique-key sibling and assert exactly one `VALIDATION_FAILED` for the invalid key, zero RPC calls for that key, and one successful RPC for the valid sibling. Keep malformed raw-span structure or a raw/parsed element mismatch as an envelope-level HTTP 400 with zero privileged calls. Rack names may repeat, and signed safe-integer `createdAt`/`updatedAt` values are accepted. Pre-count duplicate section identities before size or document validation; assert exactly one `DUPLICATE_SECTION` rejection per duplicated key, zero RPC calls for every occurrence of that key, and one RPC for each valid non-duplicated sibling. +- Table-drive the shared strict instant helper through mutation, RPC canonical, and pull paths. Reject numeric/string `0`, prose dates, date-only/space forms, February 30, invalid leap days/times/offsets, and any missing timezone. Accept valid `Z`, fractional-second, and positive/negative-offset instants and assert canonical output is the exact `toISOString()` normalization. +- Send raw `Uint8Array` bodies through the real handler. Reject a leading UTF-8 BOM, truncated sequences, overlong encodings, and isolated continuation bytes with HTTP 400 and zero admin construction/calls; accept a legitimately encoded U+FFFD scalar. Use the shared byte-golden artifact to build original bodies at exactly 262143, 262144, and 262145 bytes for one preference array element and exactly 524287, 524288, and 524289 bytes for the complete HTTP `PortalSyncPayload`. Assert inclusive limits from the original `Uint8Array.byteLength`, HTTP 413 only for full-request overflow when the preference field is present, per-section `SECTION_TOO_LARGE` for section overflow, exact scanner offsets despite whitespace/escapes/nesting, and that a large ordinary request below 9,500,000 bytes without the preference field is not subjected to the preference cap. +- Inject RPC error, null data, empty array, two rows, malformed canonical, mismatched revision, and unknown rejection reason. Each must return generic 5xx, call the logger with exactly one `{ name: safeErrorName(...) }` object, expose no payload/profile/token/secret/error message, and never emit `VALIDATION_FAILED`. Include a thrown error whose custom `name` is invalid/oversized and prove the fallback name is logged. A well-formed domain rejection continues with valid siblings. +- With real RPC calls and `Promise.all`, prove same-section concurrent base-0 writes yield one revision-1 acceptance and one canonical conflict, while different-section base-0 writes both reach revision 1 and preserve both documents. Assert one returned row per call. +- For lost acknowledgement, commit A directly, force the handler's later B RPC to fail after A has committed, discard the failed response, and retry the original A mutation at base 0. Assert the retry is a canonical revision-1 conflict and the stored revision remains 1; then retry B normally and assert convergence. +- In `mobile-sync-pull/index.test.ts`, repeat the exact auth classification matrix, seed all five typed documents through the mutation RPC, invoke first-page pull as the owner, and deep-compare every canonical wrapper to the mutation responses, including strict RFC3339 `toISOString()` normalization. Inject malformed database timestamps and string/object-key Unicode to prove a name-only-logged generic 5xx rather than silent normalization. Assert both owner predicates are applied, a cross-user profile cannot be read, an absent row is not created, later cursor pages omit `profilePreferenceSections`, and every response retains `syncTime`. + +After the exact `profile-preferences-edge-functions.md` content—including prose, comments, every fence, configuration, and the manifest—is written and reviewed, run `BackendHandoffContractTest` once. Its mismatch message prints the LF-normalized SHA-256 produced by the existing pure Kotlin implementation. Review the complete focused artifact diff, replace only the RHS of `EXPECTED_EDGE_HANDOFF_SHA256` with that concrete 64-character lowercase digest, and rerun. The empty-string sentinel must be gone. Never derive the expected value from the file, never place the digest inside the hashed handoff, and repin only when a reviewed handoff edit is intentional. The appended/inverted-executable mutation assertions must continue proving the seal detects drift. + +Run this exact portal verification sequence from the portal repository: + +```bash +supabase --version +supabase start +supabase db reset --local +supabase migration list --local +supabase test db --local +deno test --allow-env --allow-net supabase/functions/mobile-sync-push +deno test --allow-env --allow-net supabase/functions/mobile-sync-pull +supabase db lint --local --fail-on warning +supabase db advisors --local +git status --short +git diff --check +git diff --name-only +git diff -- supabase/functions/mobile-sync-push supabase/functions/mobile-sync-pull supabase/functions/_shared/profile-preference-byte-goldens.json supabase/tests/database/profile_preferences.test.sql supabase/config.toml supabase/migrations docs/profile-preferences-advisor-dispositions.md +``` + +Require Supabase CLI 2.81.3 or newer for `db advisors`. If that command is unavailable in the installed CLI, run the equivalent Supabase Advisors through the project MCP integration or Dashboard before handoff approval; do not skip it. Record every lint/advisor finding with id, severity, affected object, fix or evidence-backed disposition, command/source, and rerun result in `docs/profile-preferences-advisor-dispositions.md`. The final `git diff --name-only` must contain only the listed portal targets, the focused diff must be reviewed, and no `supabase db push`, `functions deploy`, remote migration, commit, or deployment belongs to this handoff step. + +- [ ] **Step 6: Run the handoff contract test and verify it passes** + +Run: + +```powershell +.\gradlew.bat :shared:testAndroidHostTest --tests "com.devil.phoenixproject.data.sync.BackendHandoffContractTest" -Pskip.supabase.check=true +``` + +Expected: PASS with the concrete non-sentinel Edge digest, exact golden artifact, exact 19-statement SQL envelope, new hardening fragments, and expanded manifest all sealed. + +- [ ] **Step 7: Commit the atomic backend handoff** + +```powershell +git add docs/backend-handoff/profile-preferences-supabase.sql docs/backend-handoff/profile-preferences-edge-functions.md docs/backend-handoff/profile-preference-byte-goldens.json shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/BackendHandoffContractTest.kt +git commit -m "docs(sync): define atomic profile preference edge contract" +``` + +--- + +### Task 3: Add Internal and Backward-Compatible Wire DTOs + +**Files:** +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/SyncModels.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/PortalSyncDtos.kt` +- Create: `shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/ProfilePreferenceSyncDtosTest.kt` + +**Interfaces:** +- Consumes: typed, validated section documents and repository methods from the data-foundation plan. +- Produces: internal snapshot/outcome types and additive HTTP DTOs for every later mobile task. + +- [ ] **Step 1: Write failing DTO compatibility and privacy tests** + +Create `ProfilePreferenceSyncDtosTest.kt`: + +```kotlin +package com.devil.phoenixproject.data.sync + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.putJsonArray +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import kotlinx.serialization.json.put + +class ProfilePreferenceSyncDtosTest { + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + explicitNulls = false + } + + @Test + fun `legacy responses decode without falsely acknowledging preferences`() { + val push = json.decodeFromString( + """{"syncTime":"2026-07-11T12:00:00Z"}""", + ) + val pull = json.decodeFromString( + """{"syncTime":1783771200000}""", + ) + + assertNull(push.profilePreferencesAccepted) + assertTrue(push.canonicalProfilePreferenceSections.isEmpty()) + assertTrue(push.profilePreferenceRejections.isEmpty()) + assertNull(pull.profilePreferenceSections) + + val encodedPush = json.encodeToJsonElement(push).jsonObject + val encodedPull = json.encodeToJsonElement(pull).jsonObject + assertFalse("profilePreferencesAccepted" in encodedPush) + assertEquals( + JsonArray(emptyList()), + encodedPush.getValue("canonicalProfilePreferenceSections"), + ) + assertEquals( + JsonArray(emptyList()), + encodedPush.getValue("profilePreferenceRejections"), + ) + assertFalse("profilePreferenceSections" in encodedPull) + } + + @Test + fun `mutation has exact value-only wire keys and JSON number revisions`() { + val mutation = PortalProfilePreferenceSectionMutationDto( + localProfileId = "profile-a", + section = "WORKOUT", + documentVersion = 1, + baseRevision = 4, + clientModifiedAt = "2026-07-11T12:00:00Z", + payload = buildJsonObject { + put("version", 1) + put("voiceStopEnabled", true) + }, + ) + + val encoded = json.encodeToJsonElement(mutation).jsonObject + assertEquals( + setOf( + "localProfileId", "section", "documentVersion", "baseRevision", + "clientModifiedAt", "payload", + ), + encoded.keys, + ) + assertEquals("profile-a", encoded.getValue("localProfileId").jsonPrimitive.content) + assertEquals("WORKOUT", encoded.getValue("section").jsonPrimitive.content) + assertFalse(encoded.getValue("documentVersion").jsonPrimitive.isString) + assertEquals(1, encoded.getValue("documentVersion").jsonPrimitive.int) + assertFalse(encoded.getValue("baseRevision").jsonPrimitive.isString) + assertEquals(4L, encoded.getValue("baseRevision").jsonPrimitive.long) + assertEquals( + "2026-07-11T12:00:00Z", + encoded.getValue("clientModifiedAt").jsonPrimitive.content, + ) + val payload = encoded.getValue("payload") + assertTrue(payload is JsonObject) + assertEquals(setOf("version", "voiceStopEnabled"), payload.jsonObject.keys) + assertFalse(payload.jsonObject.getValue("version").jsonPrimitive.isString) + assertTrue(payload.jsonObject.getValue("voiceStopEnabled").jsonPrimitive.boolean) + } + + @Test + fun `recursive normalized local-only names cannot enter mutation or canonical DTOs`() { + listOf( + "safeWord", + "safeWord\u0130", + "SAFE_WORD", + "safe-word-calibrated", + "adults_only_confirmed", + "adultsOnlyPrompted", + "local_generation", + "DIRTY", + "legacy-migration-version", + ).forEach { forbidden -> + val adversarial = buildJsonObject { + put("version", 1) + putJsonArray("nested") { + add(buildJsonObject { put(forbidden, "must-not-enter-wire-dto") }) + } + } + assertFailsWith(forbidden) { + PortalProfilePreferenceSectionMutationDto( + localProfileId = "profile-a", + section = "WORKOUT", + documentVersion = 1, + baseRevision = 4, + clientModifiedAt = "2026-07-11T12:00:00Z", + payload = adversarial, + ) + } + assertFailsWith(forbidden) { + PortalProfilePreferenceSectionCanonicalDto( + localProfileId = "profile-a", + section = "WORKOUT", + documentVersion = 1, + serverRevision = 7, + serverUpdatedAt = "2026-07-11T12:01:00Z", + payload = adversarial, + ) + } + } + } + + @Test + fun `canonical and rejection encode exact keys types and revision identity`() { + val canonical = PortalProfilePreferenceSectionCanonicalDto( + localProfileId = "profile-a", + section = "CORE", + documentVersion = 1, + serverRevision = 7, + serverUpdatedAt = "2026-07-11T12:01:00Z", + payload = buildJsonObject { put("weightUnit", "KG") }, + ) + val rejection = ProfilePreferenceSectionRejectionDto( + localProfileId = "profile-a", + section = "CORE", + serverRevision = 7, + reason = "REVISION_CONFLICT", + canonicalSection = canonical, + ) + + val encodedCanonical = json.encodeToJsonElement(canonical).jsonObject + assertEquals( + setOf( + "localProfileId", "section", "documentVersion", "serverRevision", + "serverUpdatedAt", "payload", + ), + encodedCanonical.keys, + ) + assertFalse(encodedCanonical.getValue("serverRevision").jsonPrimitive.isString) + assertEquals(7L, encodedCanonical.getValue("serverRevision").jsonPrimitive.long) + assertTrue(encodedCanonical.getValue("payload") is JsonObject) + + val encodedRejection = json.encodeToJsonElement(rejection).jsonObject + assertEquals( + setOf( + "localProfileId", "section", "serverRevision", "reason", "canonicalSection", + ), + encodedRejection.keys, + ) + assertFalse(encodedRejection.getValue("serverRevision").jsonPrimitive.isString) + assertEquals(7L, encodedRejection.getValue("serverRevision").jsonPrimitive.long) + assertEquals("REVISION_CONFLICT", encodedRejection.getValue("reason").jsonPrimitive.content) + assertEquals(encodedCanonical, encodedRejection.getValue("canonicalSection")) + } +} +``` + +- [ ] **Step 2: Run the DTO tests and verify missing-type failures** + +Run: + +```powershell +.\gradlew.bat :shared:testAndroidHostTest --tests "com.devil.phoenixproject.data.sync.ProfilePreferenceSyncDtosTest" -Pskip.supabase.check=true +``` + +Expected: FAIL because the preference DTOs and additive fields do not exist. + +- [ ] **Step 3: Add non-serializable internal sync models** + +Add these types to `SyncModels.kt`. Import and reuse the foundation's `com.devil.phoenixproject.domain.model.ProfilePreferenceSectionName`; do not declare a second section enum. Deliberately omit `@Serializable` from every type containing `localGeneration`. + +```kotlin +data class ProfilePreferenceSectionKey( + val localProfileId: String, + val section: com.devil.phoenixproject.domain.model.ProfilePreferenceSectionName, +) + +data class ProfilePreferenceSectionSyncDto( + val key: ProfilePreferenceSectionKey, + val documentVersion: Int, + val baseRevision: Long, + val clientModifiedAtEpochMs: Long, + val localGeneration: Long, + val payload: kotlinx.serialization.json.JsonObject, +) + +data class PreparedProfilePreferenceMutation( + val wire: PortalProfilePreferenceSectionMutationDto, + val key: ProfilePreferenceSectionKey, + val sentLocalGeneration: Long, +) + +data class CanonicalProfilePreferenceSection( + val key: ProfilePreferenceSectionKey, + val documentVersion: Int, + val serverRevision: Long, + val serverUpdatedAtEpochMs: Long, + val payload: kotlinx.serialization.json.JsonObject, +) + +data class ProfilePreferencePushOutcome( + val key: ProfilePreferenceSectionKey, + val sentLocalGeneration: Long, + val serverRevision: Long, + val canonical: CanonicalProfilePreferenceSection?, + val rejectionReason: String?, +) + +data class ProfilePreferenceSyncApplyReport( + val applied: Int = 0, + val preservedNewerLocal: Int = 0, + val ignoredUnknownProfile: Int = 0, + val invalid: Int = 0, +) + +data class ProfilePreferenceSyncIssue( + val key: ProfilePreferenceSectionKey, + val localGeneration: Long, + val reason: String, +) + +data class ProfilePreferenceDirtySnapshot( + val valid: List, + val unsyncable: List, +) + +sealed interface ProfilePreferenceCanonicalDecodeResult { + data class Valid(val section: CanonicalProfilePreferenceSection) : ProfilePreferenceCanonicalDecodeResult + data class Invalid( + val localProfileId: String, + val section: String, + val reason: String, + ) : ProfilePreferenceCanonicalDecodeResult +} +``` + +- [ ] **Step 4: Add the wire DTOs and additive fields** + +Add a recursive value-only guard and the three `@Serializable` DTOs to `PortalSyncDtos.kt`. The guard normalizes punctuation/case exactly like the Edge validator and rejects local metadata or consent/safety fields at any payload depth. Filter to ASCII alphanumerics before lowercasing, matching JavaScript's `/[^a-z0-9]/gi`; reversing those operations diverges for Unicode case expansion such as `\u0130`. `Long` fields deliberately use the default kotlinx.serialization JSON-number representation; they are not quoted strings. Task 4 prevents values outside JavaScript's exact-integer range from reaching these DTOs. + +```kotlin +private val LOCAL_ONLY_PROFILE_PREFERENCE_KEYS = setOf( + "safeword", + "safewordcalibrated", + "adultsonlyconfirmed", + "adultsonlyprompted", + "localgeneration", + "dirty", + "legacymigrationversion", +) + +private fun normalizedProfilePreferenceWireKey(key: String): String = + key + .filter { it in 'a'..'z' || it in 'A'..'Z' || it in '0'..'9' } + .lowercase() + +private fun requireValueOnlyProfilePreferencePayload(value: kotlinx.serialization.json.JsonElement) { + when (value) { + is kotlinx.serialization.json.JsonArray -> + value.forEach(::requireValueOnlyProfilePreferencePayload) + is kotlinx.serialization.json.JsonObject -> value.forEach { (key, child) -> + require(normalizedProfilePreferenceWireKey(key) !in LOCAL_ONLY_PROFILE_PREFERENCE_KEYS) { + "Local-only profile preference fields are not wire-safe" + } + requireValueOnlyProfilePreferencePayload(child) + } + else -> Unit + } +} + +@Serializable +data class PortalProfilePreferenceSectionMutationDto( + val localProfileId: String, + val section: String, + val documentVersion: Int, + val baseRevision: Long, + val clientModifiedAt: String, + val payload: kotlinx.serialization.json.JsonObject, +) { + init { + requireValueOnlyProfilePreferencePayload(payload) + } +} + +@Serializable +data class PortalProfilePreferenceSectionCanonicalDto( + val localProfileId: String, + val section: String, + val documentVersion: Int, + val serverRevision: Long, + val serverUpdatedAt: String, + val payload: kotlinx.serialization.json.JsonObject, +) { + init { + requireValueOnlyProfilePreferencePayload(payload) + } +} + +@Serializable +data class ProfilePreferenceSectionRejectionDto( + val localProfileId: String, + val section: String, + val serverRevision: Long, + val reason: String, + val canonicalSection: PortalProfilePreferenceSectionCanonicalDto? = null, +) +``` + +Add these fields with the exact defaults: + +```kotlin +// PortalSyncPayload +val profilePreferenceSections: List? = null + +// PortalSyncPushResponse +val profilePreferencesAccepted: Boolean? = null +val canonicalProfilePreferenceSections: List = emptyList() +val profilePreferenceRejections: List = emptyList() + +// PortalSyncPullResponse +val profilePreferenceSections: List? = null +``` + +Update the stale `SyncModels.kt` comment to name `PortalSyncPayload`, the actual push request class. + +- [ ] **Step 5: Run the DTO tests and verify they pass** + +Run: + +```powershell +.\gradlew.bat :shared:testAndroidHostTest --tests "com.devil.phoenixproject.data.sync.ProfilePreferenceSyncDtosTest" -Pskip.supabase.check=true +``` + +Expected: PASS. + +- [ ] **Step 6: Commit the additive wire contract** + +```powershell +git add shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/SyncModels.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/PortalSyncDtos.kt shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/ProfilePreferenceSyncDtosTest.kt +git commit -m "feat(sync): add profile preference wire contract" +``` + +--- + +### Task 4: Add the Focused SQLDelight Sync Persistence Adapter + +**Files:** +- Modify: `shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/VitruvianDatabase.sq` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/PortalSyncDtos.kt` +- Create: `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/PortalWireJson.kt` +- Create: `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/ProfilePreferenceSyncRepository.kt` +- Create: `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/ProfilePreferenceSyncCodec.kt` +- Create: `shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/sync/SqlDelightProfilePreferenceSyncRepositoryTest.kt` + +**Interfaces:** +- Consumes: schema-43 `UserProfilePreferences`, the foundation typed models/validator/codec, and Task 3's internal sync models. +- Produces: an internal reusable wire-safety guard, a strict decoded-canonical column boundary, and a sync-only repository that snapshots dirty valid sections, reports invalid sections with category-only reasons, applies push canonicals with generation/revision guards, and merges pull canonicals without creating profiles. +- Invariant: every invalid section is isolated by `(localProfileId, section, localGeneration)`, remains dirty, and is excluded from wire construction; neither raw JSON, string values, exception messages, nor payload fragments enter an issue reason or log. + +- [ ] **Step 1: Write failing row-owned-wrapper and invalid-section snapshot tests** + +Create `SqlDelightProfilePreferenceSyncRepositoryTest.kt` with an owned in-memory JDBC driver. Do not call `createTestDatabase()`: that helper intentionally hides its driver, while these boundary tests need the driver for valid-but-out-of-domain SQLite seeds. Use this exact fixture and the generated profile lookup for no-create assertions; do not construct `SqlDelightUserProfileRepository` or its unrelated safety/gamification dependencies: + +```kotlin +private lateinit var driver: JdbcSqliteDriver +private lateinit var database: VitruvianDatabase +private lateinit var foundationRepository: SqlDelightProfilePreferencesRepository +private lateinit var codec: ProfilePreferenceSyncCodec +private lateinit var repository: SqlDelightProfilePreferenceSyncRepository + +@Before +fun setup() { + driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) + VitruvianDatabase.Schema.create(driver) + database = VitruvianDatabase(driver) + foundationRepository = SqlDelightProfilePreferencesRepository(database) + codec = ProfilePreferenceSyncCodec() + repository = SqlDelightProfilePreferenceSyncRepository(database, codec) +} + +@After +fun tearDown() { + driver.close() +} + +private fun createProfile(id: String) { + database.vitruvianDatabaseQueries.insertProfile(id, id, 0, 1, 0) +} + +private fun assertProfileDoesNotExist(id: String) { + assertNull(database.vitruvianDatabaseQueries.getProfileById(id).executeAsOneOrNull()) +} +``` + +Import `JdbcSqliteDriver`, JUnit `Before`/`After`/`Test`, and the existing domain/foundation types. Insert profile `profile-a`, insert its default preference row, and write typed LED/VBT values through `SqlDelightProfilePreferencesRepository`: + +```kotlin +@Test +fun `dirty snapshot reconstructs row owned LED and VBT values as objects`() = runTest { + createProfile("profile-a") + foundationRepository.insertDefaults("profile-a") + foundationRepository.updateLed( + "profile-a", + LedPreferences(colorScheme = 7, discoModeUnlocked = true), + now = 20, + ) + foundationRepository.updateVbt( + "profile-a", + VbtPreferences(enabled = false, velocityLossThresholdPercent = 35), + now = 30, + ) + + val snapshot = repository.snapshotDirtySections() + val led = snapshot.valid.single { it.key.section == ProfilePreferenceSectionName.LED } + val vbt = snapshot.valid.single { it.key.section == ProfilePreferenceSectionName.VBT } + + assertEquals(7, led.payload.getValue("ledColorSchemeId").jsonPrimitive.int) + assertTrue(led.payload.getValue("preferences") is JsonObject) + assertNull(led.payload.getValue("preferences").jsonObject["colorScheme"]) + assertFalse(vbt.payload.getValue("vbtEnabled").jsonPrimitive.boolean) + assertTrue(vbt.payload.getValue("preferences") is JsonObject) + assertNull(vbt.payload.getValue("preferences").jsonObject["enabled"]) + assertFalse(led.payload.toString().contains("\\\"{", ignoreCase = false)) + assertFalse(vbt.payload.toString().contains("\\\"{", ignoreCase = false)) +} + +@Test +fun `malformed dirty document is reported and valid sibling remains syncable`() = runTest { + createProfile("profile-a") + foundationRepository.insertDefaults("profile-a") + database.vitruvianDatabaseQueries.updateWorkoutProfilePreferences( + workout_preferences_json = "{broken", + workout_updated_at = 20, + profile_id = "profile-a", + ) + foundationRepository.updateRack( + "profile-a", + RackPreferences(items = listOf(RackItem(id = "rack-1", name = "Bar", weightKg = 20f))), + now = 30, + ) + + val snapshot = repository.snapshotDirtySections() + + assertTrue(snapshot.valid.any { it.key.section == ProfilePreferenceSectionName.RACK }) + assertTrue(snapshot.valid.none { it.key.section == ProfilePreferenceSectionName.WORKOUT }) + assertEquals(ProfilePreferenceSectionName.WORKOUT, snapshot.unsyncable.single().key.section) + assertEquals( + ProfilePreferenceSyncIssueReason.INVALID_LOCAL_DOCUMENT.name, + snapshot.unsyncable.single().reason, + ) +} + +private companion object { + const val MAX_EXACT_JSON_INTEGER = 9_007_199_254_740_991L + const val MIN_EXACT_JSON_INTEGER = -9_007_199_254_740_991L + const val MIN_RFC3339_EPOCH_MILLIS = -62_135_596_800_000L + const val MAX_RFC3339_EPOCH_MILLIS = 253_402_300_799_999L +} + +private fun forceDirtyCoreRevision(profileId: String, revision: Long) { + driver.execute( + identifier = null, + sql = """ + UPDATE UserProfilePreferences + SET core_server_revision = ?, core_dirty = 1 + WHERE profile_id = ? + """.trimIndent(), + parameters = 2, + ) { + bindLong(0, revision) + bindString(1, profileId) + } +} + +@Test +fun `base revision max is syncable while max plus one is a dead letter`() = runTest { + val cases = listOf( + "profile-max" to MAX_EXACT_JSON_INTEGER, + "profile-over" to MAX_EXACT_JSON_INTEGER + 1, + ) + cases.forEach { (profileId, revision) -> + createProfile(profileId) + foundationRepository.insertDefaults(profileId) + forceDirtyCoreRevision(profileId, revision) + } + + val first = repository.snapshotDirtySections() + val max = first.valid.single { + it.key.localProfileId == "profile-max" && + it.key.section == ProfilePreferenceSectionName.CORE + } + assertEquals(MAX_EXACT_JSON_INTEGER, max.baseRevision) + assertTrue(first.valid.none { + it.key.localProfileId == "profile-over" && + it.key.section == ProfilePreferenceSectionName.CORE + }) + val issue = first.unsyncable.single { + it.key.localProfileId == "profile-over" && + it.key.section == ProfilePreferenceSectionName.CORE + } + assertEquals( + foundationRepository.get("profile-over").core.metadata.localGeneration, + issue.localGeneration, + ) + assertEquals( + ProfilePreferenceSyncIssueReason.UNREPRESENTABLE_JSON_INTEGER.name, + issue.reason, + ) + assertTrue(foundationRepository.get("profile-over").core.metadata.dirty) + + val second = repository.snapshotDirtySections() + assertEquals( + setOf("profile-over"), + second.unsyncable.filter { it.key.section == ProfilePreferenceSectionName.CORE } + .map { it.key.localProfileId } + .toSet(), + ) +} + +@Test +fun `rack signed timestamp bounds stay JSON numbers and overflow is dead lettered`() = runTest { + suspend fun writeRack( + profileId: String, + createdAt: Long, + updatedAt: Long, + duplicateName: Boolean = false, + ) { + createProfile(profileId) + foundationRepository.insertDefaults(profileId) + val items = buildList { + add( + RackItem( + id = "rack-1", + name = "Same name", + weightKg = 20f, + createdAt = createdAt, + updatedAt = updatedAt, + ), + ) + if (duplicateName) add( + RackItem( + id = "rack-2", + name = "Same name", + weightKg = 10f, + createdAt = createdAt, + updatedAt = updatedAt, + ), + ) + } + foundationRepository.updateRack(profileId, RackPreferences(items = items), now = 30) + } + writeRack( + "rack-bounds", + createdAt = MIN_EXACT_JSON_INTEGER, + updatedAt = MAX_EXACT_JSON_INTEGER, + duplicateName = true, + ) + writeRack("rack-created-over", MAX_EXACT_JSON_INTEGER + 1, 0) + writeRack("rack-updated-under", 0, MIN_EXACT_JSON_INTEGER - 1) + + val snapshot = repository.snapshotDirtySections() + val bounds = snapshot.valid.single { + it.key.localProfileId == "rack-bounds" && + it.key.section == ProfilePreferenceSectionName.RACK + } + val firstItem = bounds.payload.getValue("items").jsonArray.first().jsonObject + assertFalse(firstItem.getValue("createdAt").jsonPrimitive.isString) + assertFalse(firstItem.getValue("updatedAt").jsonPrimitive.isString) + assertEquals(MIN_EXACT_JSON_INTEGER, firstItem.getValue("createdAt").jsonPrimitive.long) + assertEquals(MAX_EXACT_JSON_INTEGER, firstItem.getValue("updatedAt").jsonPrimitive.long) + assertEquals(2, bounds.payload.getValue("items").jsonArray.size) + + mapOf( + "rack-created-over" to "RACK.items[0].createdAt", + "rack-updated-under" to "RACK.items[0].updatedAt", + ).forEach { (profileId, field) -> + assertTrue(snapshot.valid.none { + it.key.localProfileId == profileId && + it.key.section == ProfilePreferenceSectionName.RACK + }) + val issue = snapshot.unsyncable.single { + it.key.localProfileId == profileId && + it.key.section == ProfilePreferenceSectionName.RACK + } + assertEquals( + ProfilePreferenceSyncIssueReason.UNREPRESENTABLE_JSON_INTEGER.name, + issue.reason, + field, + ) + assertTrue(foundationRepository.get(profileId).rack.metadata.dirty) + } + + val oldGeneration = snapshot.unsyncable.single { + it.key.localProfileId == "rack-created-over" && + it.key.section == ProfilePreferenceSectionName.RACK + }.localGeneration + foundationRepository.updateRack( + "rack-created-over", + RackPreferences( + items = listOf( + RackItem( + id = "rack-1", + name = "Same name", + weightKg = 20f, + createdAt = 0, + updatedAt = 0, + ), + ), + ), + now = 40, + ) + val revalidated = repository.snapshotDirtySections().valid.single { + it.key.localProfileId == "rack-created-over" && + it.key.section == ProfilePreferenceSectionName.RACK + } + assertTrue(revalidated.localGeneration > oldGeneration) +} + +private fun forceDirtyLedColorScheme(profileId: String, colorScheme: Long) { + driver.execute( + identifier = null, + sql = """ + UPDATE UserProfilePreferences + SET led_color_scheme_id = ?, led_dirty = 1 + WHERE profile_id = ? + """.trimIndent(), + parameters = 2, + ) { + bindLong(0, colorScheme) + bindString(1, profileId) + } +} + +@Test +fun `LED color scheme validates Int32 before conversion and never wraps`() = runTest { + mapOf( + "led-max" to Int.MAX_VALUE.toLong(), + "led-over" to Int.MAX_VALUE.toLong() + 1, + "led-wraps-to-zero" to 4_294_967_296L, + ).forEach { (profileId, value) -> + createProfile(profileId) + foundationRepository.insertDefaults(profileId) + forceDirtyLedColorScheme(profileId, value) + } + + val snapshot = repository.snapshotDirtySections() + val max = snapshot.valid.single { + it.key.localProfileId == "led-max" && it.key.section == ProfilePreferenceSectionName.LED + } + assertEquals(Int.MAX_VALUE, max.payload.getValue("ledColorSchemeId").jsonPrimitive.int) + listOf("led-over", "led-wraps-to-zero").forEach { profileId -> + assertTrue(snapshot.valid.none { + it.key.localProfileId == profileId && it.key.section == ProfilePreferenceSectionName.LED + }) + assertEquals( + ProfilePreferenceSyncIssueReason.INVALID_INT32.name, + snapshot.unsyncable.single { + it.key.localProfileId == profileId && + it.key.section == ProfilePreferenceSectionName.LED + }.reason, + ) + } +} + +private fun singleExerciseDefaults(exerciseId: String) = SingleExerciseDefaultsDocument( + exerciseId = exerciseId, + setReps = emptyList(), + weightPerCableKg = 20f, + setWeightsPerCableKg = emptyList(), + progressionKg = 0f, + setRestSeconds = emptyList(), + workoutModeId = 0, + eccentricLoadPercentage = 100, + echoLevelValue = 1, + duration = 0, + isAMRAP = false, + perSetRestTime = false, +) + +@Test +fun `Postgres incompatible text and normalized local only keys dead letter only their sections`() = runTest { + createProfile("nul-profile") + foundationRepository.insertDefaults("nul-profile") + foundationRepository.updateRack( + "nul-profile", + RackPreferences( + items = listOf( + RackItem(id = "rack-nul", name = "SECRET_SENTINEL\u0000", weightKg = 20f), + ), + ), + now = 20, + ) + + createProfile("surrogate-profile") + foundationRepository.insertDefaults("surrogate-profile") + foundationRepository.updateRack( + "surrogate-profile", + RackPreferences( + items = listOf( + RackItem(id = "rack-surrogate", name = "bad\uD800", weightKg = 20f), + ), + ), + now = 20, + ) + + val forbiddenKey = "safeWord\u0130" + createProfile("local-key-profile") + foundationRepository.insertDefaults("local-key-profile") + foundationRepository.updateWorkout( + "local-key-profile", + WorkoutPreferences( + singleExerciseDefaults = mapOf( + forbiddenKey to singleExerciseDefaults(forbiddenKey), + ), + ), + now = 20, + ) + + val snapshot = repository.snapshotDirtySections() + mapOf( + ProfilePreferenceSectionKey("nul-profile", ProfilePreferenceSectionName.RACK) to + ProfilePreferenceSyncIssueReason.INVALID_TEXT_TREE, + ProfilePreferenceSectionKey("surrogate-profile", ProfilePreferenceSectionName.RACK) to + ProfilePreferenceSyncIssueReason.INVALID_TEXT_TREE, + ProfilePreferenceSectionKey("local-key-profile", ProfilePreferenceSectionName.WORKOUT) to + ProfilePreferenceSyncIssueReason.LOCAL_ONLY_WIRE_KEY, + ).forEach { (key, expectedReason) -> + assertTrue(snapshot.valid.none { it.key == key }) + val issue = snapshot.unsyncable.single { it.key == key } + assertEquals(expectedReason.name, issue.reason) + assertFalse(issue.reason.contains("SECRET_SENTINEL")) + assertFalse(issue.reason.contains(forbiddenKey)) + } + assertTrue(snapshot.valid.any { + it.key == ProfilePreferenceSectionKey( + "nul-profile", + ProfilePreferenceSectionName.CORE, + ) + }) + assertTrue(snapshot.valid.any { + it.key == ProfilePreferenceSectionKey( + "local-key-profile", + ProfilePreferenceSectionName.RACK, + ) + }) +} +``` + +Add boundary cases using the retained driver for both wrapper fields that Task 5 otherwise assumes are total. For each section timestamp column, `MIN_RFC3339_EPOCH_MILLIS` and `MAX_RFC3339_EPOCH_MILLIS` remain valid and serialize as four-digit-year RFC3339 instants; either adjacent millisecond is excluded from `valid` with only `INVALID_CLIENT_MODIFIED_AT`. Insert profiles with a blank id, an id containing U+0000, and an id containing a lone surrogate; every dirty section for those rows is excluded with only `INVALID_PROFILE_ID`, and neither the id nor a sentinel substring appears in any reason. A valid profile/section beside each invalid case remains syncable. + +The fixture's `createProfile` calls the existing generated `insertProfile` query, and its retained in-memory `driver` is used only for boundary states that the public foundation repository cannot create. Do not seed a negative `core_server_revision`: schema 43's `CHECK (core_server_revision >= 0)` correctly makes that database state impossible. Keep the codec's negative-base-revision branch as defense in depth for non-database callers. Import `jsonArray`, `jsonObject`, `jsonPrimitive`, `int`, `long`, and `boolean`; assertions inspect JSON elements, not stringified nested JSON. “Dead letter for the current generation” has an executable meaning: the issue carries that `localGeneration`, the section remains dirty, every snapshot excludes it from `valid` (so no wire DTO, chunk, or RPC retry can be created), and it remains diagnosable in `unsyncable`. A subsequent local edit increments the generation and is validated afresh. No value is rounded, wrapped, replacement-character-normalized, coerced to a string, or sent repeatedly. + +- [ ] **Step 2: Write failing generation-race and pull-merge tests** + +Add these repository tests. `coreCanonical` returns a valid `CanonicalProfilePreferenceSection` with the literal CORE payload shown here, so no fallback decoder participates: + +```kotlin +private fun coreCanonical( + revision: Long, + bodyWeightKg: Double, + updatedAt: Long = 1_783_771_200_000L, + profileId: String = "profile-a", +) = CanonicalProfilePreferenceSection( + key = ProfilePreferenceSectionKey(profileId, ProfilePreferenceSectionName.CORE), + documentVersion = 1, + serverRevision = revision, + serverUpdatedAtEpochMs = updatedAt, + payload = buildJsonObject { + put("bodyWeightKg", bodyWeightKg) + put("weightUnit", "KG") + put("weightIncrement", 0.5) + }, +) + +@Test +fun `push canonical racing newer edit advances revision without clearing dirty`() = runTest { + createProfile("profile-a") + foundationRepository.insertDefaults("profile-a") + foundationRepository.updateCore("profile-a", CoreProfilePreferences(bodyWeightKg = 80f), now = 20) + val sent = repository.snapshotDirtySections().valid.single { + it.key.section == ProfilePreferenceSectionName.CORE + } + foundationRepository.updateCore("profile-a", CoreProfilePreferences(bodyWeightKg = 90f), now = 30) + + repository.applyPushOutcomes( + listOf( + ProfilePreferencePushOutcome( + key = sent.key, + sentLocalGeneration = sent.localGeneration, + serverRevision = 4, + canonical = coreCanonical(revision = 4, bodyWeightKg = 80.0), + rejectionReason = null, + ), + ), + ) + + val current = foundationRepository.get("profile-a").core + assertEquals(90f, current.value.bodyWeightKg) + assertEquals(4, current.metadata.serverRevision) + assertTrue(current.metadata.dirty) + assertTrue(current.metadata.localGeneration > sent.localGeneration) +} + +@Test +fun `matching generation conflict canonical replaces snapshot and clears dirty`() = runTest { + createProfile("profile-a") + foundationRepository.insertDefaults("profile-a") + foundationRepository.updateCore("profile-a", CoreProfilePreferences(bodyWeightKg = 80f), now = 20) + val sent = repository.snapshotDirtySections().valid.single { + it.key.section == ProfilePreferenceSectionName.CORE + } + + repository.applyPushOutcomes( + listOf( + ProfilePreferencePushOutcome( + key = sent.key, + sentLocalGeneration = sent.localGeneration, + serverRevision = 6, + canonical = coreCanonical(revision = 6, bodyWeightKg = 85.0), + rejectionReason = "REVISION_CONFLICT", + ), + ), + ) + + val current = foundationRepository.get("profile-a").core + assertEquals(85f, current.value.bodyWeightKg) + assertEquals(6, current.metadata.serverRevision) + assertFalse(current.metadata.dirty) +} + +@Test +fun `pull updates only clean nonnewer rows and never creates unknown profile`() = runTest { + createProfile("profile-a") + foundationRepository.insertDefaults("profile-a") + foundationRepository.updateCore("profile-a", CoreProfilePreferences(bodyWeightKg = 80f), now = 20) + val sent = repository.snapshotDirtySections().valid.single { + it.key.section == ProfilePreferenceSectionName.CORE + } + repository.applyPushOutcomes( + listOf( + ProfilePreferencePushOutcome( + key = sent.key, + sentLocalGeneration = sent.localGeneration, + serverRevision = 2, + canonical = coreCanonical(revision = 2, bodyWeightKg = 80.0, updatedAt = 100), + rejectionReason = null, + ), + ), + ) + + val report = repository.applyPulledSections( + listOf( + coreCanonical(revision = 3, bodyWeightKg = 83.0), + coreCanonical(revision = 9, bodyWeightKg = 99.0).copy( + key = ProfilePreferenceSectionKey("remote-only", ProfilePreferenceSectionName.CORE), + ), + ), + ) + + val current = foundationRepository.get("profile-a").core + assertEquals(83f, current.value.bodyWeightKg) + assertEquals(3, current.metadata.serverRevision) + assertFalse(current.metadata.dirty) + assertEquals(1, report.ignoredUnknownProfile) + assertProfileDoesNotExist("remote-only") +} + +@Test +fun `dirty pull leaves payload revision timestamp generation and dirty flag unchanged`() = runTest { + createProfile("profile-a") + foundationRepository.insertDefaults("profile-a") + foundationRepository.updateCore("profile-a", CoreProfilePreferences(bodyWeightKg = 80f), now = 20) + val before = foundationRepository.get("profile-a").core + + repository.applyPulledSections(listOf(coreCanonical(revision = 3, bodyWeightKg = 83.0))) + + val after = foundationRepository.get("profile-a").core + assertEquals(before.value, after.value) + assertEquals(before.metadata.serverRevision, after.metadata.serverRevision) + assertEquals(before.metadata.updatedAt, after.metadata.updatedAt) + assertEquals(before.metadata.localGeneration, after.metadata.localGeneration) + assertTrue(after.metadata.dirty) +} + +@Test +fun `equal revision different payload repairs clean row`() = runTest { + createProfile("profile-a") + foundationRepository.insertDefaults("profile-a") + foundationRepository.updateCore("profile-a", CoreProfilePreferences(bodyWeightKg = 80f), now = 20) + val sent = repository.snapshotDirtySections().valid.single { + it.key.section == ProfilePreferenceSectionName.CORE + } + repository.applyPushOutcomes( + listOf( + ProfilePreferencePushOutcome( + key = sent.key, + sentLocalGeneration = sent.localGeneration, + serverRevision = 2, + canonical = coreCanonical(revision = 2, bodyWeightKg = 80.0, updatedAt = 100), + rejectionReason = null, + ), + ), + ) + + repository.applyPulledSections( + listOf(coreCanonical(revision = 2, bodyWeightKg = 83.0, updatedAt = 200)), + ) + + val repaired = foundationRepository.get("profile-a").core + assertEquals(83f, repaired.value.bodyWeightKg) + assertEquals(2, repaired.metadata.serverRevision) + assertEquals(200, repaired.metadata.updatedAt) + assertFalse(repaired.metadata.dirty) +} + +private fun canonical( + profileId: String, + section: ProfilePreferenceSectionName, + revision: Long, + variant: Int, + updatedAt: Long = 1_783_771_200_000L + variant, +) = CanonicalProfilePreferenceSection( + key = ProfilePreferenceSectionKey(profileId, section), + documentVersion = 1, + serverRevision = revision, + serverUpdatedAtEpochMs = updatedAt, + payload = when (section) { + ProfilePreferenceSectionName.CORE -> buildJsonObject { + put("bodyWeightKg", 80 + variant) + put("weightUnit", "KG") + put("weightIncrement", 0.5) + } + ProfilePreferenceSectionName.RACK -> buildJsonObject { + put("version", 1) + putJsonArray("items") { + add(buildJsonObject { + put("id", "rack-$variant") + put("name", "Rack $variant") + put("category", "OTHER") + put("weightKg", variant) + put("behavior", "ADDED_RESISTANCE") + put("enabled", true) + put("sortOrder", variant) + put("createdAt", variant.toLong()) + put("updatedAt", variant.toLong()) + }) + } + } + ProfilePreferenceSectionName.WORKOUT -> buildJsonObject { + put("version", 1) + put("stopAtTop", variant % 2 == 1) + } + ProfilePreferenceSectionName.LED -> buildJsonObject { + put("ledColorSchemeId", variant) + putJsonObject("preferences") { + put("version", 1) + put("discoModeUnlocked", variant % 2 == 1) + } + } + ProfilePreferenceSectionName.VBT -> buildJsonObject { + put("vbtEnabled", variant % 2 == 0) + putJsonObject("preferences") { + put("version", 1) + put("velocityLossThresholdPercent", 20 + variant) + } + } + }, +) + +private fun assertVariant(preferences: UserProfilePreferences, variant: Int) { + assertEquals((80 + variant).toFloat(), preferences.core.value.bodyWeightKg) + assertEquals(WeightUnit.KG, preferences.core.value.weightUnit) + assertEquals(0.5f, preferences.core.value.weightIncrement) + assertEquals("rack-$variant", preferences.rack.value.items.single().id) + assertEquals(variant.toFloat(), preferences.rack.value.items.single().weightKg) + assertEquals(variant % 2 == 1, preferences.workout.value.stopAtTop) + assertEquals(variant, preferences.led.value.colorScheme) + assertEquals(variant % 2 == 1, preferences.led.value.discoModeUnlocked) + assertEquals(variant % 2 == 0, preferences.vbt.value.enabled) + assertEquals(20 + variant, preferences.vbt.value.velocityLossThresholdPercent) +} + +private fun allMetadata(preferences: UserProfilePreferences) = listOf( + preferences.core.metadata, + preferences.rack.metadata, + preferences.workout.metadata, + preferences.led.metadata, + preferences.vbt.metadata, +) + +private suspend fun acknowledgeAllDirty(profileId: String, revision: Long, variant: Int) { + val sent = repository.snapshotDirtySections().valid + .filter { it.key.localProfileId == profileId } + .associateBy { it.key.section } + repository.applyPushOutcomes( + ProfilePreferenceSectionName.entries.map { section -> + val snapshot = sent.getValue(section) + ProfilePreferencePushOutcome( + key = snapshot.key, + sentLocalGeneration = snapshot.localGeneration, + serverRevision = revision, + canonical = canonical(profileId, section, revision, variant), + rejectionReason = null, + ) + }, + ) +} + +@Test +fun `matching generation push persists all five sections and row owned columns`() = runTest { + createProfile("push-all") + foundationRepository.insertDefaults("push-all") + + acknowledgeAllDirty("push-all", revision = 2, variant = 1) + + val preferences = foundationRepository.get("push-all") + assertVariant(preferences, variant = 1) + allMetadata(preferences).forEach { metadata -> + assertEquals(2, metadata.serverRevision) + assertFalse(metadata.dirty) + } + val row = database.vitruvianDatabaseQueries + .selectProfilePreferenceSyncRow("push-all") + .executeAsOne() + assertEquals(1L, row.led_color_scheme_id) + assertFalse(row.led_preferences_json.contains("colorScheme")) + assertEquals(0L, row.vbt_enabled) + assertFalse(row.vbt_preferences_json.contains("enabled")) +} + +@Test +fun `newer local generations preserve all five values while advancing revisions`() = runTest { + createProfile("race-all") + foundationRepository.insertDefaults("race-all") + val sent = repository.snapshotDirtySections().valid + .filter { it.key.localProfileId == "race-all" } + .associateBy { it.key.section } + foundationRepository.updateCore( + "race-all", + CoreProfilePreferences(95f, WeightUnit.KG, 1f), + now = 30, + ) + foundationRepository.updateRack( + "race-all", + RackPreferences(items = listOf(RackItem(id = "local", name = "Local", weightKg = 9f))), + now = 30, + ) + foundationRepository.updateWorkout( + "race-all", + WorkoutPreferences(stopAtTop = false, beepsEnabled = false), + now = 30, + ) + foundationRepository.updateLed( + "race-all", + LedPreferences(colorScheme = 9, discoModeUnlocked = false), + now = 30, + ) + foundationRepository.updateVbt( + "race-all", + VbtPreferences(enabled = true, velocityLossThresholdPercent = 45), + now = 30, + ) + + val report = repository.applyPushOutcomes( + ProfilePreferenceSectionName.entries.map { section -> + val snapshot = sent.getValue(section) + ProfilePreferencePushOutcome( + key = snapshot.key, + sentLocalGeneration = snapshot.localGeneration, + serverRevision = 4, + canonical = canonical("race-all", section, revision = 4, variant = 1), + rejectionReason = null, + ) + }, + ) + + val current = foundationRepository.get("race-all") + assertEquals(95f, current.core.value.bodyWeightKg) + assertEquals("local", current.rack.value.items.single().id) + assertFalse(current.workout.value.beepsEnabled) + assertEquals(9, current.led.value.colorScheme) + assertEquals(45, current.vbt.value.velocityLossThresholdPercent) + allMetadata(current).forEach { metadata -> + assertEquals(4, metadata.serverRevision) + assertTrue(metadata.dirty) + } + assertEquals(5, report.preservedNewerLocal) +} + +@Test +fun `clean pull applies all five sections and rejects every lower revision`() = runTest { + createProfile("pull-all") + foundationRepository.insertDefaults("pull-all") + acknowledgeAllDirty("pull-all", revision = 2, variant = 1) + + val applied = repository.applyPulledSections( + ProfilePreferenceSectionName.entries.map { section -> + canonical("pull-all", section, revision = 3, variant = 2) + }, + ) + val afterHigher = foundationRepository.get("pull-all") + assertEquals(5, applied.applied) + assertVariant(afterHigher, variant = 2) + allMetadata(afterHigher).forEach { metadata -> + assertEquals(3, metadata.serverRevision) + assertFalse(metadata.dirty) + } + + val lower = repository.applyPulledSections( + ProfilePreferenceSectionName.entries.map { section -> + canonical("pull-all", section, revision = 2, variant = 3) + }, + ) + assertEquals(0, lower.applied) + assertEquals(afterHigher, foundationRepository.get("pull-all")) +} + +@Test +fun `canonical null identity mismatch and revision mismatch remain dirty`() = runTest { + createProfile("invalid-outcome") + foundationRepository.insertDefaults("invalid-outcome") + foundationRepository.updateCore( + "invalid-outcome", + CoreProfilePreferences(bodyWeightKg = 80f), + now = 20, + ) + val sent = repository.snapshotDirtySections().valid.single { + it.key.localProfileId == "invalid-outcome" && + it.key.section == ProfilePreferenceSectionName.CORE + } + + val noCanonical = repository.applyPushOutcomes( + listOf(ProfilePreferencePushOutcome(sent.key, sent.localGeneration, 2, null, null)), + ) + assertEquals(0, noCanonical.applied) + assertTrue(foundationRepository.get("invalid-outcome").core.metadata.dirty) + + val wrongKey = repository.applyPushOutcomes( + listOf( + ProfilePreferencePushOutcome( + sent.key, + sent.localGeneration, + 2, + canonical("other-profile", ProfilePreferenceSectionName.CORE, 2, 1), + null, + ), + ), + ) + val wrongRevision = repository.applyPushOutcomes( + listOf( + ProfilePreferencePushOutcome( + sent.key, + sent.localGeneration, + 3, + canonical("invalid-outcome", ProfilePreferenceSectionName.CORE, 2, 1), + null, + ), + ), + ) + assertEquals(1, wrongKey.invalid) + assertEquals(1, wrongRevision.invalid) + assertTrue(foundationRepository.get("invalid-outcome").core.metadata.dirty) +} + +@Test +fun `canonical revision bounds and malformed payload fail closed with category only reasons`() { + val max = coreCanonical(MAX_EXACT_JSON_INTEGER, 80.0) + assertIs(codec.decodeCanonical(max)) + listOf(-1L, MAX_EXACT_JSON_INTEGER + 1).forEach { revision -> + val invalid = assertIs( + codec.decodeCanonical(coreCanonical(revision, 80.0)), + ) + assertEquals(ProfilePreferenceSyncIssueReason.INVALID_CANONICAL_REVISION, invalid.reason) + } + val sentinel = "SECRET_SENTINEL" + val malformed = canonical("profile-a", ProfilePreferenceSectionName.WORKOUT, 3, 1) + .copy(payload = buildJsonObject { + put("version", 1) + put("repCountTiming", sentinel) + }) + val invalid = assertIs( + codec.decodeCanonical(malformed), + ) + assertEquals(ProfilePreferenceSyncIssueReason.INVALID_CANONICAL_PAYLOAD, invalid.reason) + assertFalse(invalid.reason.name.contains(sentinel)) +} + +@Test +fun `malformed pulled canonical is isolated while valid sibling applies`() = runTest { + createProfile("malformed-pull") + foundationRepository.insertDefaults("malformed-pull") + acknowledgeAllDirty("malformed-pull", revision = 2, variant = 1) + val malformed = canonical( + "malformed-pull", + ProfilePreferenceSectionName.WORKOUT, + revision = 3, + variant = 2, + ).copy(payload = buildJsonObject { + put("version", 1) + put("repCountTiming", "SECRET_SENTINEL") + }) + + val report = repository.applyPulledSections( + listOf( + malformed, + canonical("malformed-pull", ProfilePreferenceSectionName.CORE, 3, 2), + ), + ) + + val current = foundationRepository.get("malformed-pull") + assertEquals(1, report.invalid) + assertEquals(1, report.applied) + assertTrue(current.workout.value.stopAtTop) + assertEquals(82f, current.core.value.bodyWeightKg) +} + +@Test +fun `matching generation requires dirty state and nonregressing revision`() = runTest { + createProfile("stale-push") + foundationRepository.insertDefaults("stale-push") + val sent = repository.snapshotDirtySections().valid.single { + it.key.localProfileId == "stale-push" && + it.key.section == ProfilePreferenceSectionName.CORE + } + repository.applyPushOutcomes( + listOf( + ProfilePreferencePushOutcome( + sent.key, + sent.localGeneration, + 6, + canonical("stale-push", ProfilePreferenceSectionName.CORE, 6, 6), + null, + ), + ), + ) + val stale = ProfilePreferencePushOutcome( + sent.key, + sent.localGeneration, + 5, + canonical("stale-push", ProfilePreferenceSectionName.CORE, 5, 5), + null, + ) + assertEquals(0, repository.applyPushOutcomes(listOf(stale)).applied) + + driver.execute( + null, + "UPDATE UserProfilePreferences SET core_dirty = 1 WHERE profile_id = ?", + 1, + ) { bindString(0, "stale-push") } + assertEquals(0, repository.applyPushOutcomes(listOf(stale)).applied) + val current = foundationRepository.get("stale-push").core + assertEquals(86f, current.value.bodyWeightKg) + assertEquals(6, current.metadata.serverRevision) + assertTrue(current.metadata.dirty) +} + +@Test +fun `semantic numeric equality does not become equal revision divergence`() = runTest { + createProfile("numeric-equality") + foundationRepository.insertDefaults("numeric-equality") + val integerToken = coreCanonical(0, 80.0, profileId = "numeric-equality").copy( + payload = PortalWireJson.parseToJsonElement( + """{"bodyWeightKg":80,"weightUnit":"KG","weightIncrement":0.5}""", + ).jsonObject, + ) + driver.execute( + null, + """ + UPDATE UserProfilePreferences + SET body_weight_kg = 80, weight_unit = 'KG', weight_increment = 0.5, + core_dirty = 0 + WHERE profile_id = ? + """.trimIndent(), + 1, + ) { bindString(0, "numeric-equality") } + val normalizedRow = database.vitruvianDatabaseQueries + .selectProfilePreferenceSyncRow("numeric-equality") + .executeAsOne() + + assertFalse(codec.hasCanonicalDivergence(normalizedRow, integerToken)) + assertTrue( + codec.hasCanonicalDivergence( + normalizedRow, + coreCanonical(0, 83.0, profileId = "numeric-equality"), + ), + ) +} +``` + +Also table-drive the local `*_updated_at` boundary test across CORE/RACK/WORKOUT/LED/VBT so every section accessor proves both inclusive RFC3339 bounds and both adjacent dead letters. At the repository boundary, place blank, U+0000, and lone-surrogate unknown canonical profile ids beside one valid known-profile canonical. Assert every malformed id increments `invalid`, none increments `ignoredUnknownProfile`, no malformed id is bound into a database lookup, and the valid sibling still applies. Extend the codec boundary cases to cover canonical profile-id validation, both adjacent canonical timestamp bounds as `INVALID_CANONICAL_TIMESTAMP`, and RACK `createdAt`/`updatedAt` exact-integer overflow as `UNREPRESENTABLE_JSON_INTEGER`; none may mutate persistence or expose sentinel content. + +- [ ] **Step 3: Run the repository tests and verify the red state** + +Run: + +```powershell +.\gradlew.bat :shared:testAndroidHostTest --tests "*SqlDelightProfilePreferenceSyncRepositoryTest*" -Pskip.supabase.check=true +``` + +Expected: FAIL because the sync repository, codec, and guarded SQLDelight queries do not exist. + +- [ ] **Step 4: Add the exact dirty, acknowledgement, and pull query matrix** + +Add `selectDirtyProfilePreferenceRows` and `selectProfilePreferenceSyncRow` to `VitruvianDatabase.sq`: + +```sql +selectDirtyProfilePreferenceRows: +SELECT * +FROM UserProfilePreferences +WHERE core_dirty = 1 + OR rack_dirty = 1 + OR workout_dirty = 1 + OR led_dirty = 1 + OR vbt_dirty = 1 +ORDER BY profile_id; + +selectProfilePreferenceSyncRow: +SELECT * +FROM UserProfilePreferences +WHERE profile_id = :profile_id; + +selectChangedRowCount: +SELECT changes(); +``` + +For push canonicals, add these ten query names. Each `apply...ForGeneration` updates only its section's value columns, `*_updated_at`, `*_server_revision`, and `*_dirty = 0`, guarded by `profile_id`, equality with `:sent_local_generation`, `*_dirty = 1`, and `*_server_revision <= :server_revision`. The dirty guard rejects a delayed replay after a clean pull; the monotonic guard rejects revision regression even if a corrupt/replayed caller re-marks the same generation dirty. Each `advance...ForNewerGeneration` updates only `*_server_revision`, guarded by a strictly newer generation and a lower stored revision. + +| Section | Matching-generation query | Newer-generation query | Value columns | +|---|---|---|---| +| CORE | `applyCoreCanonicalForGeneration` | `advanceCoreRevisionForNewerGeneration` | `body_weight_kg`, `weight_unit`, `weight_increment` | +| RACK | `applyRackCanonicalForGeneration` | `advanceRackRevisionForNewerGeneration` | `equipment_rack_json` | +| WORKOUT | `applyWorkoutCanonicalForGeneration` | `advanceWorkoutRevisionForNewerGeneration` | `workout_preferences_json` | +| LED | `applyLedCanonicalForGeneration` | `advanceLedRevisionForNewerGeneration` | `led_color_scheme_id`, `led_preferences_json` | +| VBT | `applyVbtCanonicalForGeneration` | `advanceVbtRevisionForNewerGeneration` | `vbt_enabled`, `vbt_preferences_json` | + +CORE establishes the exact SQL shape: + +```sql +applyCoreCanonicalForGeneration: +UPDATE UserProfilePreferences +SET body_weight_kg = :body_weight_kg, + weight_unit = :weight_unit, + weight_increment = :weight_increment, + core_updated_at = :server_updated_at, + core_server_revision = :server_revision, + core_dirty = 0 +WHERE profile_id = :profile_id + AND core_local_generation = :sent_local_generation + AND core_dirty = 1 + AND core_server_revision <= :server_revision; + +advanceCoreRevisionForNewerGeneration: +UPDATE UserProfilePreferences +SET core_server_revision = :server_revision +WHERE profile_id = :profile_id + AND core_local_generation > :sent_local_generation + AND core_server_revision < :server_revision; +``` + +The RACK/WORKOUT/LED/VBT matching queries use the value columns in the table above and the corresponding section metadata names, including both the exact `*_dirty = 1` and `*_server_revision <= :server_revision` guards. Their newer-generation queries contain no value, timestamp, generation, or dirty assignment. + +For pull canonicals, add `applyPulledCoreWhenClean`, `applyPulledRackWhenClean`, `applyPulledWorkoutWhenClean`, `applyPulledLedWhenClean`, and `applyPulledVbtWhenClean`. They update the same value/revision/timestamp columns and retain `dirty = 0`; their guard is exactly `profile_id = :profile_id AND *_dirty = 0 AND *_server_revision <= :server_revision`. The `<=` intentionally repairs equal-revision content divergence while rejecting lower server revisions. CORE establishes the exact shape: + +```sql +applyPulledCoreWhenClean: +UPDATE UserProfilePreferences +SET body_weight_kg = :body_weight_kg, + weight_unit = :weight_unit, + weight_increment = :weight_increment, + core_updated_at = :server_updated_at, + core_server_revision = :server_revision, + core_dirty = 0 +WHERE profile_id = :profile_id + AND core_dirty = 0 + AND core_server_revision <= :server_revision; +``` + +- [ ] **Step 5: Implement the strict sync codec and complete wire wrappers** + +First create `PortalWireJson.kt`; Task 4's codec uses this shared instance and must compile independently before Task 5 adds raw request scanning: + +```kotlin +package com.devil.phoenixproject.data.sync + +import kotlinx.serialization.json.Json + +internal val PortalWireJson = Json { + ignoreUnknownKeys = true + isLenient = true + encodeDefaults = true + explicitNulls = false +} +``` + +In `PortalSyncDtos.kt`, replace the private recursive guard with this internal reusable result. Both DTO initializers continue to call `requireValueOnlyProfilePreferencePayload`; Task 4 calls `profilePreferenceWireSafetyViolation` before a local section can enter the valid snapshot. This is the one implementation of ASCII-filter-then-lowercase local-only normalization and PostgreSQL-compatible UTF-16 validation; do not duplicate it in the codec: + +```kotlin +internal enum class ProfilePreferenceWireSafetyViolation { + INVALID_TEXT_TREE, + LOCAL_ONLY_KEY, +} + +private fun isPostgresCompatibleText(value: String): Boolean { + var index = 0 + while (index < value.length) { + val codeUnit = value[index] + when { + codeUnit == '\u0000' -> return false + codeUnit in '\uD800'..'\uDBFF' -> { + if (index + 1 >= value.length || value[index + 1] !in '\uDC00'..'\uDFFF') { + return false + } + index += 1 + } + codeUnit in '\uDC00'..'\uDFFF' -> return false + } + index += 1 + } + return true +} + +internal fun profilePreferenceWireSafetyViolation( + value: kotlinx.serialization.json.JsonElement, +): ProfilePreferenceWireSafetyViolation? = when (value) { + is kotlinx.serialization.json.JsonArray -> value.firstNotNullOfOrNull( + ::profilePreferenceWireSafetyViolation, + ) + is kotlinx.serialization.json.JsonObject -> { + value.entries.firstNotNullOfOrNull { (key, child) -> + when { + !isPostgresCompatibleText(key) -> + ProfilePreferenceWireSafetyViolation.INVALID_TEXT_TREE + normalizedProfilePreferenceWireKey(key) in LOCAL_ONLY_PROFILE_PREFERENCE_KEYS -> + ProfilePreferenceWireSafetyViolation.LOCAL_ONLY_KEY + else -> profilePreferenceWireSafetyViolation(child) + } + } + } + is kotlinx.serialization.json.JsonPrimitive -> if ( + value.isString && !isPostgresCompatibleText(value.content) + ) { + ProfilePreferenceWireSafetyViolation.INVALID_TEXT_TREE + } else { + null + } +} + +private fun requireValueOnlyProfilePreferencePayload( + value: kotlinx.serialization.json.JsonElement, +) { + require(profilePreferenceWireSafetyViolation(value) == null) { + "Profile preference payload is not wire-safe" + } +} +``` + +Then create `ProfilePreferenceSyncCodec.kt` as an internal class. It parses foundation codec output back to `JsonObject`, so JSON documents remain objects: + +```kotlin +internal class ProfilePreferenceSyncCodec { + companion object { + const val MAX_EXACT_JSON_INTEGER = 9_007_199_254_740_991L + const val MIN_EXACT_JSON_INTEGER = -9_007_199_254_740_991L + const val MIN_RFC3339_EPOCH_MILLIS = -62_135_596_800_000L + const val MAX_RFC3339_EPOCH_MILLIS = 253_402_300_799_999L + } + + private fun exactJsonIntegerIssue(value: Long): ProfilePreferenceSyncIssueReason? = + if (value in MIN_EXACT_JSON_INTEGER..MAX_EXACT_JSON_INTEGER) { + null + } else { + ProfilePreferenceSyncIssueReason.UNREPRESENTABLE_JSON_INTEGER + } + + private fun document(encoded: String): JsonObject = + PortalWireJson.parseToJsonElement(encoded).jsonObject + + fun ledPayload(value: LedPreferences): JsonObject = buildJsonObject { + put("ledColorSchemeId", value.colorScheme) + put("preferences", document(ProfilePreferencesCodec.encodeLed(value))) + } + + fun vbtPayload(value: VbtPreferences): JsonObject = buildJsonObject { + put("vbtEnabled", value.enabled) + put("preferences", document(ProfilePreferencesCodec.encodeVbt(value))) + } + + fun corePayload(value: CoreProfilePreferences): JsonObject = buildJsonObject { + put("bodyWeightKg", value.bodyWeightKg) + put("weightUnit", value.weightUnit.name) + put("weightIncrement", value.weightIncrement) + } + + fun rackPayload(value: RackPreferences): JsonObject = + document(ProfilePreferencesCodec.encodeRack(value)) + + fun workoutPayload(value: WorkoutPreferences): JsonObject = + document(ProfilePreferencesCodec.encodeWorkout(value)) + + fun normalizedPayload(value: DecodedProfilePreferenceValue): JsonObject = when (value) { + is DecodedProfilePreferenceValue.Core -> corePayload(value.value) + is DecodedProfilePreferenceValue.Rack -> rackPayload(value.value) + is DecodedProfilePreferenceValue.Workout -> workoutPayload(value.value) + is DecodedProfilePreferenceValue.Led -> ledPayload(value.value) + is DecodedProfilePreferenceValue.Vbt -> vbtPayload(value.value) + } + + fun validateCanonicalPayload( + section: ProfilePreferenceSectionName, + documentVersion: Int, + payload: JsonObject, + ): ProfilePreferencePayloadValidation { + val reason = canonicalPayloadIssue(section, documentVersion, payload) + return ProfilePreferencePayloadValidation( + isValid = reason == null, + reason = reason?.name.orEmpty(), + ) + } +} + +internal data class ProfilePreferencePayloadValidation( + val isValid: Boolean, + val reason: String, +) + +internal enum class ProfilePreferenceSyncIssueReason { + INVALID_PROFILE_ID, + INVALID_LOCAL_DOCUMENT, + INVALID_CLIENT_MODIFIED_AT, + UNREPRESENTABLE_JSON_INTEGER, + INVALID_INT32, + INVALID_TEXT_TREE, + LOCAL_ONLY_WIRE_KEY, + UNSUPPORTED_DOCUMENT_VERSION, + INVALID_CANONICAL_PAYLOAD, + INVALID_CANONICAL_REVISION, + INVALID_CANONICAL_TIMESTAMP, +} + +internal sealed interface DecodedProfilePreferenceValue { + data class Core(val value: CoreProfilePreferences) : DecodedProfilePreferenceValue + data class Rack(val value: RackPreferences) : DecodedProfilePreferenceValue + data class Workout(val value: WorkoutPreferences) : DecodedProfilePreferenceValue + data class Led(val value: LedPreferences) : DecodedProfilePreferenceValue + data class Vbt(val value: VbtPreferences) : DecodedProfilePreferenceValue +} + +internal data class DecodedProfilePreferenceColumns( + val key: ProfilePreferenceSectionKey, + val documentVersion: Int, + val serverRevision: Long, + val serverUpdatedAtEpochMs: Long, + val value: DecodedProfilePreferenceValue, + val normalizedPayload: JsonObject, +) { + val section: ProfilePreferenceSectionName get() = key.section +} + +internal sealed interface ProfilePreferenceCanonicalColumnsResult { + data class Valid( + val columns: DecodedProfilePreferenceColumns, + ) : ProfilePreferenceCanonicalColumnsResult + + data class Invalid( + val reason: ProfilePreferenceSyncIssueReason, + ) : ProfilePreferenceCanonicalColumnsResult +} + +internal data class EncodedDirtyProfilePreferenceRow( + val valid: List, + val unsyncable: List, +) +``` + +Add `encodeDirtyRow(row)`, `decodeAndValidateTypedValue(section, payload)`, `canonicalPayloadIssue(section, documentVersion, payload)`, and `decodeCanonical(canonical)` methods. `encodeDirtyRow` returns `EncodedDirtyProfilePreferenceRow` and catches each section independently; no exception from one section may abort a valid sibling. It decodes local JSON only through the foundation codec, checks the returned `ProfilePreferenceValidity`, and never syncs a fallback `.value` from an invalid document. + +Before constructing a `ProfilePreferenceSectionSyncDto`, apply these checks in order and emit only the fixed `ProfilePreferenceSyncIssueReason.name` as `reason`: + +1. Before section decoding, require `row.profile_id.isNotBlank()` and require `profilePreferenceWireSafetyViolation(JsonPrimitive(row.profile_id)) == null`. A blank or PostgreSQL-incompatible id yields `INVALID_PROFILE_ID` for every dirty section in that row; no DTO is constructed. The issue key may retain the id for local repair identity, but no later log may print that raw key. +2. A negative base revision is `INVALID_LOCAL_DOCUMENT`; a base revision outside the exact JSON integer interval is `UNREPRESENTABLE_JSON_INTEGER`. +3. Require the section's `*_updated_at` in `MIN_RFC3339_EPOCH_MILLIS..MAX_RFC3339_EPOCH_MILLIS`; otherwise use `INVALID_CLIENT_MODIFIED_AT`. These inclusive bounds are exactly `0001-01-01T00:00:00.000Z` through `9999-12-31T23:59:59.999Z`, keeping Task 5's `Instant.fromEpochMilliseconds(...).toString()` within the Edge four-digit-year contract. +4. For RACK, every signed `createdAt` and `updatedAt` must be in the exact JSON integer interval. Repeated rack names remain valid; duplicate IDs remain invalid through the foundation validator. +5. Before `row.led_color_scheme_id.toInt()`, require the stored `Long` to be in `Int.MIN_VALUE.toLong()..Int.MAX_VALUE.toLong()`; otherwise use `INVALID_INT32`. Schema 43 already excludes negative values, but the codec performs the complete conversion guard. +6. Build the exact typed payload and call `profilePreferenceWireSafetyViolation`. Map `INVALID_TEXT_TREE` and `LOCAL_ONLY_KEY` to their corresponding sync issue reason. This must happen in Task 4 so Task 5's DTO constructor cannot throw while mapping the full valid snapshot. + +The issue retains the current profile/section generation and stays out of `valid`, so serialization, chunking, and RPC retry cannot see it. Valid `Long` values use `JsonPrimitive(Long)` and remain JSON numbers. Never retain an exception message, raw JSON, invalid string, numeric value, or payload fragment in a reason. + +`canonicalPayloadIssue` first requires document version 1, then the reusable wire-safety guard, then direct typed decoding/validation. It returns only a `ProfilePreferenceSyncIssueReason`; `runCatching` may contain decoder exceptions locally, but their messages are discarded. A decoded RACK canonical also requires every `createdAt`/`updatedAt` in the exact JSON-integer interval. `decodeCanonical` requires a nonblank PostgreSQL-compatible `localProfileId`, `serverUpdatedAtEpochMs` in the inclusive four-digit-year range, and `serverRevision` in `0..MAX_EXACT_JSON_INTEGER`, returning the corresponding fixed `INVALID_PROFILE_ID`, `INVALID_CANONICAL_TIMESTAMP`, `INVALID_CANONICAL_REVISION`, or `UNREPRESENTABLE_JSON_INTEGER` category otherwise. It returns `Valid` with the exact key/revision/timestamp, typed value, and `normalizedPayload(value)` only after all checks pass. + +Use this exact typed decoding boundary; kotlinx.serialization document defaults remain compatible, while wrapper fields and JSON types are mandatory: + +```kotlin +private fun decodeAndValidateTypedValue( + section: ProfilePreferenceSectionName, + payload: JsonObject, +): DecodedProfilePreferenceValue { + val decoded = when (section) { + ProfilePreferenceSectionName.CORE -> DecodedProfilePreferenceValue.Core( + CoreProfilePreferences( + bodyWeightKg = payload.getValue("bodyWeightKg").jsonPrimitive.float, + weightUnit = WeightUnit.valueOf(payload.getValue("weightUnit").jsonPrimitive.content), + weightIncrement = payload.getValue("weightIncrement").jsonPrimitive.float, + ), + ) + ProfilePreferenceSectionName.RACK -> DecodedProfilePreferenceValue.Rack( + PortalWireJson.decodeFromJsonElement(payload), + ) + ProfilePreferenceSectionName.WORKOUT -> DecodedProfilePreferenceValue.Workout( + PortalWireJson.decodeFromJsonElement(payload), + ) + ProfilePreferenceSectionName.LED -> DecodedProfilePreferenceValue.Led( + PortalWireJson.decodeFromJsonElement( + payload.getValue("preferences").jsonObject, + ).copy( + colorScheme = payload.getValue("ledColorSchemeId").jsonPrimitive.int, + ), + ) + ProfilePreferenceSectionName.VBT -> DecodedProfilePreferenceValue.Vbt( + PortalWireJson.decodeFromJsonElement( + payload.getValue("preferences").jsonObject, + ).copy( + enabled = payload.getValue("vbtEnabled").jsonPrimitive.boolean, + ), + ) + } + val errors = when (decoded) { + is DecodedProfilePreferenceValue.Core -> ProfilePreferencesValidator.core(decoded.value) + is DecodedProfilePreferenceValue.Rack -> ProfilePreferencesValidator.rack(decoded.value) + is DecodedProfilePreferenceValue.Workout -> ProfilePreferencesValidator.workout(decoded.value) + is DecodedProfilePreferenceValue.Led -> ProfilePreferencesValidator.led(decoded.value) + is DecodedProfilePreferenceValue.Vbt -> ProfilePreferencesValidator.vbt(decoded.value) + } + require(errors.isEmpty()) { errors.joinToString(",") } + return decoded +} +``` + +Implement the version/safety/category boundary exactly once and have both public codec entry points call it: + +```kotlin +private fun canonicalPayloadIssue( + section: ProfilePreferenceSectionName, + documentVersion: Int, + payload: JsonObject, +): ProfilePreferenceSyncIssueReason? { + if (documentVersion != 1) { + return ProfilePreferenceSyncIssueReason.UNSUPPORTED_DOCUMENT_VERSION + } + when (profilePreferenceWireSafetyViolation(payload)) { + ProfilePreferenceWireSafetyViolation.INVALID_TEXT_TREE -> + return ProfilePreferenceSyncIssueReason.INVALID_TEXT_TREE + ProfilePreferenceWireSafetyViolation.LOCAL_ONLY_KEY -> + return ProfilePreferenceSyncIssueReason.LOCAL_ONLY_WIRE_KEY + null -> Unit + } + val explicitEmbeddedVersion = runCatching { + when (section) { + ProfilePreferenceSectionName.CORE -> null + ProfilePreferenceSectionName.RACK, + ProfilePreferenceSectionName.WORKOUT -> + payload["version"]?.jsonPrimitive?.int + ProfilePreferenceSectionName.LED, + ProfilePreferenceSectionName.VBT -> + payload.getValue("preferences").jsonObject["version"]?.jsonPrimitive?.int + } + }.getOrElse { + return ProfilePreferenceSyncIssueReason.INVALID_CANONICAL_PAYLOAD + } + if (explicitEmbeddedVersion != null && explicitEmbeddedVersion != documentVersion) { + return ProfilePreferenceSyncIssueReason.UNSUPPORTED_DOCUMENT_VERSION + } + val decoded = runCatching { + decodeAndValidateTypedValue(section, payload) + }.getOrElse { + return ProfilePreferenceSyncIssueReason.INVALID_CANONICAL_PAYLOAD + } + val decodedVersion = when (decoded) { + is DecodedProfilePreferenceValue.Core -> 1 + is DecodedProfilePreferenceValue.Rack -> decoded.value.version + is DecodedProfilePreferenceValue.Workout -> decoded.value.version + is DecodedProfilePreferenceValue.Led -> decoded.value.version + is DecodedProfilePreferenceValue.Vbt -> decoded.value.version + } + return if (decodedVersion == documentVersion) { + null + } else { + ProfilePreferenceSyncIssueReason.UNSUPPORTED_DOCUMENT_VERSION + } +} + +fun decodeCanonical( + canonical: CanonicalProfilePreferenceSection, +): ProfilePreferenceCanonicalColumnsResult { + if (canonical.key.localProfileId.isBlank() || + !isPostgresCompatibleText(canonical.key.localProfileId) + ) { + return ProfilePreferenceCanonicalColumnsResult.Invalid( + ProfilePreferenceSyncIssueReason.INVALID_PROFILE_ID, + ) + } + if (canonical.serverUpdatedAtEpochMs !in + MIN_RFC3339_EPOCH_MILLIS..MAX_RFC3339_EPOCH_MILLIS + ) { + return ProfilePreferenceCanonicalColumnsResult.Invalid( + ProfilePreferenceSyncIssueReason.INVALID_CANONICAL_TIMESTAMP, + ) + } + if (canonical.serverRevision !in 0..MAX_EXACT_JSON_INTEGER) { + return ProfilePreferenceCanonicalColumnsResult.Invalid( + ProfilePreferenceSyncIssueReason.INVALID_CANONICAL_REVISION, + ) + } + canonicalPayloadIssue( + canonical.key.section, + canonical.documentVersion, + canonical.payload, + )?.let { reason -> + return ProfilePreferenceCanonicalColumnsResult.Invalid(reason) + } + val value = runCatching { + decodeAndValidateTypedValue(canonical.key.section, canonical.payload) + }.getOrElse { + return ProfilePreferenceCanonicalColumnsResult.Invalid( + ProfilePreferenceSyncIssueReason.INVALID_CANONICAL_PAYLOAD, + ) + } + return ProfilePreferenceCanonicalColumnsResult.Valid( + DecodedProfilePreferenceColumns( + key = canonical.key, + documentVersion = canonical.documentVersion, + serverRevision = canonical.serverRevision, + serverUpdatedAtEpochMs = canonical.serverUpdatedAtEpochMs, + value = value, + normalizedPayload = normalizedPayload(value), + ), + ) +} +``` + +`decodeCanonical` never substitutes defaults after malformed JSON, wrong JSON types, invalid enums, non-finite/range-invalid values, wire-unsafe text/keys, unsafe revisions, or version mismatches. Kotlin serialization defaults remain compatible only for omitted valid version-1 document fields. + +- [ ] **Step 6: Implement the internal repository with one transaction per profile** + +Create `ProfilePreferenceSyncRepository.kt`: + +```kotlin +/** Sync-layer boundary; bind only in SyncModule and do not expose through profile/domain APIs. */ +interface ProfilePreferenceSyncRepository { + suspend fun snapshotDirtySections(): ProfilePreferenceDirtySnapshot + suspend fun applyPushOutcomes( + outcomes: List, + ): ProfilePreferenceSyncApplyReport + suspend fun applyPulledSections( + sections: List, + ): ProfilePreferenceSyncApplyReport +} + +internal class SqlDelightProfilePreferenceSyncRepository( + private val database: VitruvianDatabase, + private val codec: ProfilePreferenceSyncCodec, +) : ProfilePreferenceSyncRepository { + private val queries = database.vitruvianDatabaseQueries + + override suspend fun snapshotDirtySections(): ProfilePreferenceDirtySnapshot { + val encoded = queries.selectDirtyProfilePreferenceRows().executeAsList() + .map(codec::encodeDirtyRow) + return ProfilePreferenceDirtySnapshot( + valid = encoded.flatMap { it.valid }, + unsyncable = encoded.flatMap { it.unsyncable }, + ) + } + + override suspend fun applyPushOutcomes( + outcomes: List, + ): ProfilePreferenceSyncApplyReport { + var applied = 0 + var preserved = 0 + var invalid = 0 + outcomes.groupBy { it.key.localProfileId }.forEach { (_, profileOutcomes) -> + database.transaction { + profileOutcomes.forEach { outcome -> + val canonical = outcome.canonical ?: return@forEach + if (canonical.key != outcome.key || + canonical.serverRevision != outcome.serverRevision + ) { + invalid++ + return@forEach + } + val decoded = codec.decodeCanonical(canonical) + if (decoded is ProfilePreferenceCanonicalColumnsResult.Invalid) { + invalid++ + return@forEach + } + val columns = + (decoded as ProfilePreferenceCanonicalColumnsResult.Valid).columns + if (applyCanonicalForGeneration(columns, outcome.sentLocalGeneration)) { + applied++ + } else if (advanceRevisionForNewerGeneration(columns, outcome.sentLocalGeneration)) { + preserved++ + } + } + } + } + return ProfilePreferenceSyncApplyReport( + applied = applied, + preservedNewerLocal = preserved, + invalid = invalid, + ) + } + + override suspend fun applyPulledSections( + sections: List, + ): ProfilePreferenceSyncApplyReport { + var applied = 0 + var unknown = 0 + var invalid = 0 + val validColumns = sections.mapNotNull { canonical -> + when (val decoded = codec.decodeCanonical(canonical)) { + is ProfilePreferenceCanonicalColumnsResult.Invalid -> { + invalid++ + null + } + is ProfilePreferenceCanonicalColumnsResult.Valid -> decoded.columns + } + } + validColumns.groupBy { it.key.localProfileId }.forEach { (profileId, columnsForProfile) -> + database.transaction { + if (queries.selectProfilePreferenceSyncRow(profileId).executeAsOneOrNull() == null) { + unknown += columnsForProfile.size + return@transaction + } + columnsForProfile.forEach { columns -> + if (applyPulledWhenClean(columns)) applied++ + } + } + } + return ProfilePreferenceSyncApplyReport( + applied = applied, + ignoredUnknownProfile = unknown, + invalid = invalid, + ) + } +} +``` + +A null push canonical is an intentional no-op and does not increment `invalid`; its section remains dirty. Every pull canonical is decoded before grouping or any database lookup, so an invalid profile id is never bound to SQL or misclassified as unknown. A present canonical is defense-in-depth validated at the repository boundary even though Task 5's pull adapter performs the same wrapper checks before Task 8 dispatches it. Key mismatch, canonical/outcome revision mismatch, unsafe canonical id/timestamp/revision/RACK integer, malformed payload, or wire-safety failure increments `invalid` and performs no SQL mutation. Never log or throw with the invalid content. + +Add this codec result and method; `currentState` uses the same row-owned LED/VBT wrapper methods as `encodeDirtyRow`: + +```kotlin +internal data class CurrentProfilePreferenceSyncState( + val serverRevision: Long, + val dirty: Boolean, + val normalizedPayload: JsonObject?, +) + +fun currentState( + row: com.devil.phoenixproject.database.UserProfilePreferences, + section: ProfilePreferenceSectionName, +): CurrentProfilePreferenceSyncState = when (section) { + ProfilePreferenceSectionName.CORE -> CurrentProfilePreferenceSyncState( + row.core_server_revision, + row.core_dirty == 1L, + runCatching { + CoreProfilePreferences( + row.body_weight_kg.toFloat(), + WeightUnit.valueOf(row.weight_unit), + row.weight_increment.toFloat(), + ) + }.getOrNull()?.takeIf { ProfilePreferencesValidator.core(it).isEmpty() } + ?.let(::corePayload), + ) + ProfilePreferenceSectionName.RACK -> CurrentProfilePreferenceSyncState( + row.rack_server_revision, + row.rack_dirty == 1L, + ProfilePreferencesCodec.decodeRack(row.equipment_rack_json) + .takeIf { it.validity is ProfilePreferenceValidity.Valid } + ?.value + ?.let(::rackPayload), + ) + ProfilePreferenceSectionName.WORKOUT -> CurrentProfilePreferenceSyncState( + row.workout_server_revision, + row.workout_dirty == 1L, + ProfilePreferencesCodec.decodeWorkout(row.workout_preferences_json) + .takeIf { it.validity is ProfilePreferenceValidity.Valid } + ?.value + ?.let(::workoutPayload), + ) + ProfilePreferenceSectionName.LED -> CurrentProfilePreferenceSyncState( + row.led_server_revision, + row.led_dirty == 1L, + row.led_color_scheme_id + .takeIf { it in Int.MIN_VALUE.toLong()..Int.MAX_VALUE.toLong() } + ?.toInt() + ?.let { ProfilePreferencesCodec.decodeLed(row.led_preferences_json, it) } + ?.takeIf { it.validity is ProfilePreferenceValidity.Valid } + ?.value + ?.let(::ledPayload), + ) + ProfilePreferenceSectionName.VBT -> CurrentProfilePreferenceSyncState( + row.vbt_server_revision, + row.vbt_dirty == 1L, + ProfilePreferencesCodec.decodeVbt( + row.vbt_preferences_json, + row.vbt_enabled == 1L, + ).takeIf { it.validity is ProfilePreferenceValidity.Valid } + ?.value + ?.let(::vbtPayload), + ) +} + +fun hasCanonicalDivergence( + row: com.devil.phoenixproject.database.UserProfilePreferences, + columns: DecodedProfilePreferenceColumns, +): Boolean { + val current = currentState(row, columns.section) + return !current.dirty && + current.serverRevision == columns.serverRevision && + current.normalizedPayload != columns.normalizedPayload +} + +fun hasCanonicalDivergence( + row: com.devil.phoenixproject.database.UserProfilePreferences, + canonical: CanonicalProfilePreferenceSection, +): Boolean = when (val decoded = decodeCanonical(canonical)) { + is ProfilePreferenceCanonicalColumnsResult.Invalid -> false + is ProfilePreferenceCanonicalColumnsResult.Valid -> + hasCanonicalDivergence(row, decoded.columns) +} +``` + +Before `applyPulledWhenClean`, read the row through `selectProfilePreferenceSyncRow` and use the decoded columns' normalized typed payload. Numeric token spelling (`80` versus `80.0`), omitted fields that decode to the same version-1 defaults, and object key order must not count as divergence. A null current normalized payload means the clean local row is malformed and therefore differs from a valid canonical. Log only the trusted section enum for a true invariant repair, then apply the canonical query: + +```kotlin +if (codec.hasCanonicalDivergence(row, columns)) { + Logger.w("ProfilePreferenceSync") { + "Repairing equal-revision canonical divergence section=${columns.section.name}" + } +} +``` + +Never log the profile id/key, either payload, decoder exception messages, or invalid values. + +Implement the three dispatch helpers as exhaustive `when (columns.value)` calls, using the typed value carried by `DecodedProfilePreferenceColumns`: + +- CORE binds Float values as `Double`, `weightUnit.name`, and the canonical revision/timestamp. +- RACK and WORKOUT persist `ProfilePreferencesCodec.encodeRack/encodeWorkout`. +- LED persists `colorScheme.toLong()` separately and `ProfilePreferencesCodec.encodeLed`, which omits the row-owned color scheme. +- VBT persists `enabled` as `1L`/`0L` separately and `ProfilePreferencesCodec.encodeVbt`, which omits the row-owned enabled flag. + +Each branch calls the exact section query from Step 4. Immediately after each update, call `queries.selectChangedRowCount().executeAsOne()` and return whether it is greater than zero; add `selectChangedRowCount: SELECT changes();` beside the query matrix. No SELECT or second mutation may occur between the guarded UPDATE and this scalar read. A push outcome without a canonical is a no-op and remains dirty. Task 7 rejects duplicate response-key cardinality, while this repository independently rejects canonical/outcome identity mismatches. + +- [ ] **Step 7: Run repository and foundation regression tests** + +Run: + +```powershell +.\gradlew.bat :shared:testAndroidHostTest --tests "*SqlDelightProfilePreferenceSyncRepositoryTest*" --tests "*SqlDelightProfilePreferencesRepositoryTest*" -Pskip.supabase.check=true +``` + +Expected: PASS, including category-only malformed-local dead letters, valid-sibling isolation, exact integer and LED Int32 boundaries, NUL/lone-surrogate/local-only-key isolation, all-five matching/newer-generation push dispatch, row-owned LED/VBT persistence, all-five higher/equal/lower pull dispatch, semantic numeric equality, true equal-revision repair, dirty-pull preservation, malformed-canonical isolation, canonical-null/identity/revision fail-closed behavior, and generated-query unknown-profile no-create behavior. + +- [ ] **Step 8: Commit the sync persistence boundary** + +```powershell +git add shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/VitruvianDatabase.sq shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/PortalSyncDtos.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/PortalWireJson.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/ProfilePreferenceSyncRepository.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/ProfilePreferenceSyncCodec.kt shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/sync/SqlDelightProfilePreferenceSyncRepositoryTest.kt +git commit -m "feat(sync): add profile preference sync repository" +``` + +--- + +### Task 5: Add Exact Wire JSON, Adapters, and Preference Chunk Planning + +**Files:** +- Read: `docs/backend-handoff/profile-preference-byte-goldens.json` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/ProfilePreferenceSyncCodec.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/PortalWireJson.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/PortalSyncAdapter.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/PortalPullAdapter.kt` +- Create: `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/ProfilePreferenceSyncPlanner.kt` +- Create: `shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/PortalSyncAdapterProfilePreferencesTest.kt` +- Create: `shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/PortalPullAdapterProfilePreferencesTest.kt` +- Create: `shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/ProfilePreferenceSyncPlannerTest.kt` + +**Interfaces:** +- Consumes: Task 3's internal/wire DTOs and Task 4's strict sync codec. A section in Task 4's `valid` snapshot already has a nonblank PostgreSQL-compatible profile id, an RFC3339-representable client timestamp, an exact JSON-number revision, and a typed wire-safe payload. +- Produces: valid mutation envelopes, canonicals accepted through Task 4's `decodeCanonical` boundary, deterministic request chunks that never exceed either byte cap, and fixed-category unsyncable-section diagnostics. + +- [ ] **Step 1: Write failing adapter tests** + +Create tests that assert the timestamp/revision/generation boundary and strict pull validation. Use only fixed `ProfilePreferenceSyncIssueReason.name` values in invalid results: + +```kotlin +@Test +fun `push adapter maps audit timestamp but keeps generation off wire`() { + val source = ProfilePreferenceSectionSyncDto( + key = ProfilePreferenceSectionKey("profile-a", ProfilePreferenceSectionName.CORE), + documentVersion = 1, + baseRevision = 9_007_199_254_740_991L, + clientModifiedAtEpochMs = 1_783_771_200_000L, + localGeneration = 9, + payload = buildJsonObject { + put("bodyWeightKg", 82.5) + put("weightUnit", "KG") + put("weightIncrement", 0.5) + }, + ) + + val prepared = PortalSyncAdapter.toPortalProfilePreferenceMutation(source) + + assertEquals(9L, prepared.sentLocalGeneration) + assertEquals(9_007_199_254_740_991L, prepared.wire.baseRevision) + assertEquals("CORE", prepared.wire.section) + assertEquals("2026-07-11T12:00:00Z", prepared.wire.clientModifiedAt) + val encoded = PortalWireJson.encodeToJsonElement( + PortalProfilePreferenceSectionMutationDto.serializer(), + prepared.wire, + ).jsonObject + assertFalse(encoded.getValue("baseRevision").jsonPrimitive.isString) + assertEquals( + 9_007_199_254_740_991L, + encoded.getValue("baseRevision").jsonPrimitive.long, + ) + assertFalse("localGeneration" in encoded) +} + +@Test +fun `pull adapter rejects document version mismatch without fallback`() { + val wire = PortalProfilePreferenceSectionCanonicalDto( + localProfileId = "profile-a", + section = "VBT", + documentVersion = 2, + serverRevision = 3, + serverUpdatedAt = "2026-07-11T12:00:00.000Z", + payload = buildJsonObject { + put("vbtEnabled", true) + putJsonObject("preferences") { put("version", 1) } + }, + ) + + val invalid = assertIs( + PortalPullAdapter.toCanonicalProfilePreferenceSection(wire), + ) + assertEquals(ProfilePreferenceSyncIssueReason.UNSUPPORTED_DOCUMENT_VERSION.name, invalid.reason) +} + +private fun coreCanonicalWire( + revision: Long = 3, + localProfileId: String = "profile-a", + serverUpdatedAt: String = "2026-07-11T12:00:00.000Z", +) = PortalProfilePreferenceSectionCanonicalDto( + localProfileId = localProfileId, + section = "CORE", + documentVersion = 1, + serverRevision = revision, + serverUpdatedAt = serverUpdatedAt, + payload = buildJsonObject { + put("bodyWeightKg", 82.5) + put("weightUnit", "KG") + put("weightIncrement", 0.5) + }, +) + +@Test +fun `pull adapter enforces exact canonical revision interval`() { + assertIs( + PortalPullAdapter.toCanonicalProfilePreferenceSection( + coreCanonicalWire(revision = 9_007_199_254_740_991L), + ), + ) + + listOf(-1L, 9_007_199_254_740_992L).forEach { revision -> + val invalid = assertIs( + PortalPullAdapter.toCanonicalProfilePreferenceSection( + coreCanonicalWire(revision = revision), + ), + ) + assertEquals(ProfilePreferenceSyncIssueReason.INVALID_CANONICAL_REVISION.name, invalid.reason) + } +} + +@Test +fun `pull adapter rejects wrapper identity timestamp and section with category only`() { + val sentinel = "SECRET_REMOTE_SENTINEL" + val cases = listOf( + coreCanonicalWire(localProfileId = " ") to + ProfilePreferenceSyncIssueReason.INVALID_PROFILE_ID, + coreCanonicalWire(localProfileId = "$sentinel\u0000") to + ProfilePreferenceSyncIssueReason.INVALID_PROFILE_ID, + coreCanonicalWire(serverUpdatedAt = "+10000-01-01T00:00:00.000Z") to + ProfilePreferenceSyncIssueReason.INVALID_CANONICAL_TIMESTAMP, + coreCanonicalWire(serverUpdatedAt = "+002026-07-11T12:00:00.000Z") to + ProfilePreferenceSyncIssueReason.INVALID_CANONICAL_TIMESTAMP, + coreCanonicalWire().copy(section = sentinel) to + ProfilePreferenceSyncIssueReason.UNSUPPORTED_SECTION, + ) + + cases.forEach { (wire, expectedReason) -> + val invalid = assertIs( + PortalPullAdapter.toCanonicalProfilePreferenceSection(wire), + ) + assertEquals(expectedReason.name, invalid.reason) + assertFalse(sentinel in invalid.reason) + } +} +``` + +Place the push test in `PortalSyncAdapterProfilePreferencesTest.kt` and every pull fixture/test in `PortalPullAdapterProfilePreferencesTest.kt`, with the required Kotlin test and JSON imports. The `-1/MAX/MAX+1` table is mandatory: Task 7 must never receive an unsafe revision in a nominally valid canonical. + +- [ ] **Step 2: Write failing planner boundary tests** + +Create `ProfilePreferenceSyncPlannerTest.kt` with deterministic fixtures and exact encoded-size assertions: + +```kotlin +@Test +fun `planner isolates oversized section and keeps valid siblings`() { + val valid = preparedMutation("profile-a", ProfilePreferenceSectionName.CORE, payloadChars = 64) + val oversized = preparedMutation( + "profile-a", + ProfilePreferenceSectionName.RACK, + payloadChars = MAX_PROFILE_PREFERENCE_SECTION_BYTES + 1, + ) + + val plan = planProfilePreferencePushChunks(basePayload(), listOf(oversized, valid)) + + assertEquals(1, plan.unsyncable.size) + assertEquals(ProfilePreferenceSectionName.RACK, plan.unsyncable.single().key.section) + assertEquals(1L, plan.unsyncable.single().localGeneration) + assertEquals(ProfilePreferenceSyncIssueReason.SECTION_TOO_LARGE.name, plan.unsyncable.single().reason) + assertEquals(listOf(ProfilePreferenceSectionName.CORE), plan.chunks.single().ledger.keys.map { it.section }) +} + +@Test +fun `every planned request stays within preference request cap`() { + val mutations = (0 until 12).map { index -> + preparedMutation("profile-$index", ProfilePreferenceSectionName.WORKOUT, payloadChars = 90_000) + } + + val plan = planProfilePreferencePushChunks(basePayload(), mutations) + + assertTrue(plan.chunks.size > 1) + plan.chunks.forEach { chunk -> + val bytes = encodePortalSyncPayload(chunk.payload).rawBytes.size + assertTrue(bytes <= MAX_PROFILE_PREFERENCE_REQUEST_BYTES) + } +} + +@Test +fun `shared raw byte goldens pin scanner spans and inclusive boundaries`() { + val recipes = byteGoldenRecipes() + assertEquals(1, recipes.version) + + recipes.sectionTargetBytes.forEach { target -> + val sectionRaw = fillAsciiPadding( + recipes.sectionRawTemplate, + recipes.paddingMarker, + target, + ) + val completeRaw = "{\"profilePreferenceSections\":[$sectionRaw]}" + val span = scanProfilePreferenceElementSpans(completeRaw).single() + assertEquals(sectionRaw, completeRaw.substring(span.start, span.endExclusive)) + assertEquals( + target, + completeRaw.substring(span.start, span.endExclusive).encodeToByteArray().size, + ) + assertEquals( + target == 262_145, + completeRaw.substring(span.start, span.endExclusive).encodeToByteArray().size > + MAX_PROFILE_PREFERENCE_SECTION_BYTES, + ) + assertTrue("\"weightKg\":20.0" in sectionRaw) + assertTrue("\"createdAt\":-1e3" in sectionRaw) + assertTrue("π界🙂" in sectionRaw) + assertTrue("\\\"" in sectionRaw) + assertTrue("\\\\" in sectionRaw) + val decodedMutation = PortalWireJson.decodeFromString( + PortalProfilePreferenceSectionMutationDto.serializer(), + sectionRaw, + ) + assertEquals("profile-a", decodedMutation.localProfileId) + assertEquals("RACK", decodedMutation.section) + assertEquals(0L, decodedMutation.baseRevision) + assertTrue(decodedMutation.payload.getValue("items") is JsonArray) + PortalWireJson.parseToJsonElement(completeRaw) + } + + recipes.requestTargetBytes.forEach { target -> + val sectionRaw = recipes.sectionRawTemplate.replace(recipes.paddingMarker, "x") + val requestTemplate = recipes.requestRawTemplate.replace( + recipes.sectionMarker, + sectionRaw, + ) + val completeRaw = fillAsciiPadding( + requestTemplate, + recipes.paddingMarker, + target, + ) + assertEquals(target, completeRaw.encodeToByteArray().size) + assertEquals( + target == 524_289, + completeRaw.encodeToByteArray().size > MAX_PROFILE_PREFERENCE_REQUEST_BYTES, + ) + val span = scanProfilePreferenceElementSpans(completeRaw).single() + assertEquals(sectionRaw, completeRaw.substring(span.start, span.endExclusive)) + val decodedRequest = PortalWireJson.decodeFromString( + PortalSyncPayload.serializer(), + completeRaw, + ) + assertEquals("golden-device", decodedRequest.deviceId) + assertEquals("profile-a", decodedRequest.profileId) + assertEquals(1, decodedRequest.profilePreferenceSections?.size) + assertEquals("RACK", decodedRequest.profilePreferenceSections?.single()?.section) + } +} +``` + +The shared raw artifact is a scanner/decoder oracle. Because the production serializer uses `encodeDefaults = true`, add separate tests over the real `encodePortalSyncPayload` output; do not claim that the hand-authored request template is the serializer's byte output: + +```kotlin +@Test +fun `planner enforces actual reencoded section boundaries`() { + val recipes = byteGoldenRecipes() + recipes.sectionTargetBytes.forEach { target -> + val raw = fillAsciiPadding( + recipes.sectionRawTemplate, + recipes.paddingMarker, + target, + ) + val wire = PortalWireJson.decodeFromString( + PortalProfilePreferenceSectionMutationDto.serializer(), + raw, + ) + val section = ProfilePreferenceSectionName.valueOf(wire.section) + val prepared = PreparedProfilePreferenceMutation( + wire = wire, + key = ProfilePreferenceSectionKey(wire.localProfileId, section), + sentLocalGeneration = 1, + ) + val reencoded = encodePortalSyncPayload(minimalPayload(listOf(prepared))) + val actualBytes = reencoded.preferenceElementByteCount( + reencoded.preferenceElementSpans.single(), + ) + assertEquals(target, actualBytes) + + val plan = planProfilePreferencePushChunks(basePayload(), listOf(prepared)) + if (target <= MAX_PROFILE_PREFERENCE_SECTION_BYTES) { + assertTrue(plan.unsyncable.isEmpty()) + assertEquals(listOf(prepared.key), plan.chunks.single().ledger.keys.toList()) + } else { + assertTrue(plan.chunks.isEmpty()) + assertEquals( + ProfilePreferenceSyncIssueReason.SECTION_TOO_LARGE.name, + plan.unsyncable.single().reason, + ) + } + } +} + +@Test +fun `actual encoder request boundary is inclusive and overflow is split`() { + listOf(MAX_PROFILE_PREFERENCE_REQUEST_BYTES, MAX_PROFILE_PREFERENCE_REQUEST_BYTES + 1) + .forEach { target -> + val mutations = requestSizedMutations(target) + val encoded = encodePortalSyncPayload(minimalPayload(mutations)) + assertEquals(target, encoded.rawBytes.size) + assertTrue( + encoded.preferenceElementSpans.all { span -> + encoded.preferenceElementByteCount(span) <= + MAX_PROFILE_PREFERENCE_SECTION_BYTES + }, + ) + + val plan = planProfilePreferencePushChunks(basePayload(), mutations) + assertTrue(plan.unsyncable.isEmpty()) + if (target == MAX_PROFILE_PREFERENCE_REQUEST_BYTES) { + assertEquals(1, plan.chunks.size) + assertEquals(target, encodePortalSyncPayload(plan.chunks.single().payload).rawBytes.size) + } else { + assertTrue(plan.chunks.size > 1) + } + plan.chunks.forEach { chunk -> + assertTrue( + encodePortalSyncPayload(chunk.payload).rawBytes.size <= + MAX_PROFILE_PREFERENCE_REQUEST_BYTES, + ) + assertEquals( + chunk.payload.profilePreferenceSections.orEmpty().size, + chunk.ledger.size, + ) + } + } +} + +@Test +fun `one item request overflow is diagnosed and never emitted`() { + val base = PortalSyncPayload( + deviceId = "x".repeat(MAX_PROFILE_PREFERENCE_REQUEST_BYTES), + lastSync = 0, + ) + val mutation = preparedMutation("profile-a", ProfilePreferenceSectionName.CORE, 32) + + val plan = planProfilePreferencePushChunks(base, listOf(mutation)) + + assertTrue(plan.chunks.isEmpty()) + assertEquals(ProfilePreferenceSyncIssueReason.REQUEST_TOO_LARGE.name, plan.unsyncable.single().reason) +} + +@Test +fun `planner excludes every duplicate key and preserves unique sibling`() { + val first = preparedMutation("profile-a", ProfilePreferenceSectionName.CORE, 32) + val second = first.copy(sentLocalGeneration = 2) + val sibling = preparedMutation("profile-b", ProfilePreferenceSectionName.RACK, 32) + + val plan = planProfilePreferencePushChunks(basePayload(), listOf(second, sibling, first)) + + val duplicateIssues = plan.unsyncable.filter { it.key == first.key } + assertEquals(listOf(1L, 2L), duplicateIssues.map { it.localGeneration }) + assertTrue( + duplicateIssues.all { + it.reason == ProfilePreferenceSyncIssueReason.DUPLICATE_SECTION.name + }, + ) + assertEquals(listOf(sibling.key), plan.chunks.single().ledger.keys.toList()) +} + +@Test +fun `planner is permutation deterministic and strips profile metadata`() { + val sentinel = "SECRET_PROFILE_SENTINEL" + val base = basePayload().copy( + profileId = "$sentinel\u0000", + profileName = sentinel, + allProfiles = listOf(LocalProfileDto(sentinel, sentinel, 0)), + ) + val mutations = List(6) { index -> + preparedMutation("profile-$index", ProfilePreferenceSectionName.RACK, 100_000) + } + + val forward = planProfilePreferencePushChunks(base, mutations) + val reversed = planProfilePreferencePushChunks(base, mutations.reversed()) + + assertEquals( + forward.chunks.map { encodePortalSyncPayload(it.payload).raw }, + reversed.chunks.map { encodePortalSyncPayload(it.payload).raw }, + ) + assertEquals( + forward.chunks.map { it.ledger.entries.toList() }, + reversed.chunks.map { it.ledger.entries.toList() }, + ) + forward.chunks.forEach { chunk -> + assertNull(chunk.payload.profileId) + assertNull(chunk.payload.profileName) + assertNull(chunk.payload.allProfiles) + assertFalse(sentinel in encodePortalSyncPayload(chunk.payload).raw) + assertEquals(chunk.payload.profilePreferenceSections.orEmpty().size, chunk.ledger.size) + } +} + +@Test +fun `scanner decodes escaped top level key and ignores nested decoy`() { + val escapedKey = "\\u0070rofilePreferenceSections" + val element = """{"text":"brackets ] } , [ { and quote \" and slash \\"}""" + val raw = """{"nested":{"profilePreferenceSections":[0]},"$escapedKey":[$element]}""" + + val span = scanProfilePreferenceElementSpans(raw).single() + + assertEquals(element, raw.substring(span.start, span.endExclusive)) +} + +@Test +fun `scanner rejects escaped duplicate and malformed structure with fixed messages`() { + val escapedKey = "\\u0070rofilePreferenceSections" + val duplicate = """{"profilePreferenceSections":[],"$escapedKey":[]}""" + assertEquals( + "DUPLICATE_PROFILE_PREFERENCE_SECTIONS", + assertFailsWith { + scanProfilePreferenceElementSpans(duplicate) + }.message, + ) + + val sentinel = "SECRET_SCAN_SENTINEL" + mapOf( + "[]" to "INVALID_JSON_ROOT", + """{"profilePreferenceSections":[}""" to "INVALID_JSON_STRUCTURE", + """{"profilePreferenceSections":[]} $sentinel""" to "TRAILING_JSON_DATA", + ).forEach { (raw, expected) -> + val error = assertFailsWith { + scanProfilePreferenceElementSpans(raw) + } + assertEquals(expected, error.message) + assertFalse(sentinel in error.message.orEmpty()) + } + assertTrue(scanProfilePreferenceElementSpans("""{"other":[]}""").isEmpty()) + assertTrue(scanProfilePreferenceElementSpans("""{"profilePreferenceSections":[]}""").isEmpty()) +} +``` + +Import `readProjectFile`, `kotlinx.serialization.Serializable`, `JsonArray`, `assertFailsWith`, `assertNotNull`, and `assertNull`. Add the serializable `ProfilePreferenceByteGoldenRecipes` shape with fields matching the exact Task 2 artifact, plus `fillAsciiPadding(template, marker, targetBytes)`. The helper requires exactly one marker, removes it to measure the UTF-8 fixed bytes, requires a nonnegative padding count, inserts only ASCII `x`, and asserts the result is exactly `targetBytes`. Add these deterministic mutation helpers in the same test file: + +```kotlin +private fun preparedMutation( + profileId: String, + section: ProfilePreferenceSectionName, + payloadChars: Int, +): PreparedProfilePreferenceMutation { + val key = ProfilePreferenceSectionKey(profileId, section) + return PreparedProfilePreferenceMutation( + wire = PortalProfilePreferenceSectionMutationDto( + localProfileId = profileId, + section = section.name, + documentVersion = 1, + baseRevision = 0, + clientModifiedAt = "2026-07-11T12:00:00Z", + payload = buildJsonObject { put("padding", "x".repeat(payloadChars)) }, + ), + key = key, + sentLocalGeneration = 1, + ) +} + +private fun byteGoldenRecipes() = + PortalWireJson.decodeFromString( + assertNotNull( + readProjectFile("docs/backend-handoff/profile-preference-byte-goldens.json"), + ), + ) + +private fun minimalPayload( + mutations: List, +) = PortalSyncPayload( + deviceId = "device", + platform = "android", + lastSync = 0, + profilePreferenceSections = mutations.map { it.wire }, +) + +private fun requestSizedMutations(targetBytes: Int): List { + val seeds = List(3) { index -> + preparedMutation("profile-$index", ProfilePreferenceSectionName.RACK, payloadChars = 0) + } + val fixedBytes = encodePortalSyncPayload(minimalPayload(seeds)).rawBytes.size + val paddingBytes = targetBytes - fixedBytes + require(paddingBytes >= 0) + return seeds.mapIndexed { index, seed -> + val padding = paddingBytes / seeds.size + + if (index < paddingBytes % seeds.size) 1 else 0 + seed.copy( + wire = seed.wire.copy( + payload = buildJsonObject { put("padding", "x".repeat(padding)) }, + ), + ) + } +} + +private fun basePayload() = PortalSyncPayload( + deviceId = "device", + lastSync = 0, + profileId = "profile-a", +) +``` + +- [ ] **Step 3: Run the tests and verify missing adapter/planner failures** + +Run: + +```powershell +.\gradlew.bat :shared:testAndroidHostTest --tests "*PortalSyncAdapterProfilePreferencesTest*" --tests "*PortalPullAdapterProfilePreferencesTest*" --tests "*ProfilePreferenceSyncPlannerTest*" -Pskip.supabase.check=true +``` + +Expected: FAIL because Task 4's `PortalWireJson` has no raw encoder/scanner yet and the adapter methods, constants, and planner are absent. + +- [ ] **Step 4: Extend the shared wire JSON boundary with raw spans** + +Modify Task 4's `PortalWireJson.kt`; keep its existing `PortalWireJson` configuration byte-for-byte and add this import: + +```kotlin +import kotlinx.serialization.encodeToString +``` + +Then add one raw-wire encoding boundary used by both planning and transport: + +```kotlin +internal data class RawJsonSpan(val start: Int, val endExclusive: Int) + +internal data class EncodedPortalSyncPayload( + val raw: String, + val rawBytes: ByteArray, + val preferenceElementSpans: List, +) { + fun preferenceElementByteCount(span: RawJsonSpan): Int = + raw.substring(span.start, span.endExclusive).encodeToByteArray().size +} + +internal fun encodePortalSyncPayload(payload: PortalSyncPayload): EncodedPortalSyncPayload { + val raw = PortalWireJson.encodeToString(PortalSyncPayload.serializer(), payload) + val spans = scanProfilePreferenceElementSpans(raw) + check(spans.size == payload.profilePreferenceSections.orEmpty().size) { + "Serialized profile preference span count mismatch" + } + return EncodedPortalSyncPayload(raw, raw.encodeToByteArray(), spans) +} +``` + +Implement `scanProfilePreferenceElementSpans(raw)` as a small index scanner over the complete encoded root object. Decode every valid JSON escape in top-level property names before comparing them, require at most one decoded `profilePreferenceSections` key, recursively skip JSON values while tracking object/array nesting, and scan strings by honoring every backslash escape so quotes, commas, and brackets inside strings never change structure. A nested property with the same name is a decoy, not the top-level field. For the preference array, pin each element's start after leading JSON whitespace and its exclusive end immediately after the value but before separator whitespace/comma/closing bracket. Require matching delimiters, no trailing non-whitespace, and an array span count equal to the typed list. + +All scanner failures are `IllegalArgumentException`s with one of these fixed messages and no raw substring, property value, or exception message appended: `INVALID_JSON_ROOT`, `INVALID_JSON_STRUCTURE`, `INVALID_PROFILE_PREFERENCE_ARRAY`, `DUPLICATE_PROFILE_PREFERENCE_SECTIONS`, or `TRAILING_JSON_DATA`. Include a direct `{"profilePreferenceSections":{}}` regression for `INVALID_PROFILE_PREFERENCE_ARRAY`. Measure only `raw.substring(start, endExclusive).encodeToByteArray()`; neither this helper nor its callers may reconstruct an element with `JSON.stringify`, `toString`, or a second serialization. The shared decimal/exponent/escape/multibyte goldens and the escaped-key/nested-decoy tests are the executable scanner oracle. + +- [ ] **Step 5: Implement the push and pull adapters** + +First extend Task 4's category enum in `ProfilePreferenceSyncCodec.kt`; this is the complete enum after Task 5. Keep the Task 4 companion's `MIN_RFC3339_EPOCH_MILLIS` and `MAX_RFC3339_EPOCH_MILLIS` constants at `-62_135_596_800_000L` and `253_402_300_799_999L` so push and pull share the same four-digit-year interval: + +```kotlin +internal enum class ProfilePreferenceSyncIssueReason { + INVALID_PROFILE_ID, + INVALID_LOCAL_DOCUMENT, + INVALID_CLIENT_MODIFIED_AT, + UNREPRESENTABLE_JSON_INTEGER, + INVALID_INT32, + INVALID_TEXT_TREE, + LOCAL_ONLY_WIRE_KEY, + UNSUPPORTED_SECTION, + UNSUPPORTED_DOCUMENT_VERSION, + INVALID_CANONICAL_PAYLOAD, + INVALID_CANONICAL_REVISION, + INVALID_CANONICAL_TIMESTAMP, + SECTION_TOO_LARGE, + REQUEST_TOO_LARGE, + DUPLICATE_SECTION, +} +``` + +Add this push mapper to `PortalSyncAdapter`: + +```kotlin +fun toPortalProfilePreferenceMutation( + section: ProfilePreferenceSectionSyncDto, +): PreparedProfilePreferenceMutation = PreparedProfilePreferenceMutation( + wire = PortalProfilePreferenceSectionMutationDto( + localProfileId = section.key.localProfileId, + section = section.key.section.name, + documentVersion = section.documentVersion, + baseRevision = section.baseRevision, + clientModifiedAt = kotlin.time.Instant + .fromEpochMilliseconds(section.clientModifiedAtEpochMs) + .toString(), + payload = section.payload, + ), + key = section.key, + sentLocalGeneration = section.localGeneration, +) +``` + +Add this pull mapper to `PortalPullAdapter`. Parse the wrapper identity and timestamp first, construct one candidate, and then call Task 4's stronger `decodeCanonical` boundary so document, payload, and exact JSON revision checks cannot drift. `Instant.parse` alone is too permissive: Kotlin 2.4 accepts an expanded `+002026` year and normalizes it to 2026, while the Edge contract requires exactly four unsigned year digits. Add `ProfilePreferenceSyncCodec.parseStrictRfc3339EpochMilliseconds`: use `matchEntire` with the complete Edge grammar, validate year/calendar/clock and offsets through `23:59`, rebuild the validated local date-time with `Z`, parse that local instant, and then subtract a positive offset or add a negative offset in milliseconds. Do not parse the original offset form directly, because Kotlin rejects Edge-valid offsets above `18:00`. Test exact `+23:59` and `-23:59` conversions as well as expanded-year rejection. The repository repeats the same decode before SQL mutation as defense in depth: + +```kotlin +fun toCanonicalProfilePreferenceSection( + dto: PortalProfilePreferenceSectionCanonicalDto, +): ProfilePreferenceCanonicalDecodeResult { + fun invalid(reason: ProfilePreferenceSyncIssueReason) = + ProfilePreferenceCanonicalDecodeResult.Invalid( + localProfileId = dto.localProfileId, + section = dto.section, + reason = reason.name, + ) + + if (dto.localProfileId.isBlank() || + profilePreferenceWireSafetyViolation(JsonPrimitive(dto.localProfileId)) != null + ) { + return invalid(ProfilePreferenceSyncIssueReason.INVALID_PROFILE_ID) + } + val section = ProfilePreferenceSectionName.entries.firstOrNull { it.name == dto.section } + ?: return invalid(ProfilePreferenceSyncIssueReason.UNSUPPORTED_SECTION) + val updatedAt = ProfilePreferenceSyncCodec.parseStrictRfc3339EpochMilliseconds( + dto.serverUpdatedAt, + ) + if (updatedAt == null || + updatedAt !in ProfilePreferenceSyncCodec.MIN_RFC3339_EPOCH_MILLIS.. + ProfilePreferenceSyncCodec.MAX_RFC3339_EPOCH_MILLIS + ) { + return invalid(ProfilePreferenceSyncIssueReason.INVALID_CANONICAL_TIMESTAMP) + } + + val candidate = CanonicalProfilePreferenceSection( + key = ProfilePreferenceSectionKey(dto.localProfileId, section), + documentVersion = dto.documentVersion, + serverRevision = dto.serverRevision, + serverUpdatedAtEpochMs = updatedAt, + payload = dto.payload, + ) + return when (val decoded = ProfilePreferenceSyncCodec().decodeCanonical(candidate)) { + is ProfilePreferenceCanonicalColumnsResult.Invalid -> invalid(decoded.reason) + is ProfilePreferenceCanonicalColumnsResult.Valid -> + ProfilePreferenceCanonicalDecodeResult.Valid(candidate) + } +} +``` + +Import `kotlinx.serialization.json.JsonPrimitive` in `PortalPullAdapter.kt`. Do not catch or retain decoder exception messages; every invalid result carries only an enum name. + +- [ ] **Step 6: Implement deterministic section/request chunking** + +Create `ProfilePreferenceSyncPlanner.kt`: + +```kotlin +package com.devil.phoenixproject.data.sync + +internal const val MAX_PROFILE_PREFERENCE_SECTION_BYTES = 256 * 1024 +internal const val MAX_PROFILE_PREFERENCE_REQUEST_BYTES = 512 * 1024 + +internal data class ProfilePreferencePushChunk( + val payload: PortalSyncPayload, + val ledger: Map, +) + +internal data class ProfilePreferencePushPlan( + val chunks: List, + val unsyncable: List, +) + +internal fun planProfilePreferencePushChunks( + basePayload: PortalSyncPayload, + mutations: List, +): ProfilePreferencePushPlan { + fun preferencePayload(items: List) = PortalSyncPayload( + deviceId = basePayload.deviceId, + platform = basePayload.platform, + lastSync = basePayload.lastSync, + profilePreferenceSections = items.map { it.wire }, + ) + + val sorted = mutations.sortedWith( + compareBy( + { it.key.localProfileId }, + { it.key.section.ordinal }, + { it.sentLocalGeneration }, + ), + ) + val duplicateKeys = sorted.groupingBy { it.key } + .eachCount() + .filterValues { it > 1 } + .keys + val valid = mutableListOf() + val issues = mutableListOf() + + sorted.forEach { mutation -> + if (mutation.key in duplicateKeys) { + issues += ProfilePreferenceSyncIssue( + key = mutation.key, + localGeneration = mutation.sentLocalGeneration, + reason = ProfilePreferenceSyncIssueReason.DUPLICATE_SECTION.name, + ) + return@forEach + } + + val oneElement = encodePortalSyncPayload(preferencePayload(listOf(mutation))) + val sectionBytes = oneElement.preferenceElementByteCount( + oneElement.preferenceElementSpans.single(), + ) + val reason = when { + sectionBytes > MAX_PROFILE_PREFERENCE_SECTION_BYTES -> + ProfilePreferenceSyncIssueReason.SECTION_TOO_LARGE + oneElement.rawBytes.size > MAX_PROFILE_PREFERENCE_REQUEST_BYTES -> + ProfilePreferenceSyncIssueReason.REQUEST_TOO_LARGE + else -> null + } + if (reason != null) { + issues += ProfilePreferenceSyncIssue( + key = mutation.key, + localGeneration = mutation.sentLocalGeneration, + reason = reason.name, + ) + return@forEach + } + valid += mutation + } + + val chunks = mutableListOf() + var current = mutableListOf() + fun emit() { + if (current.isEmpty()) return + val payload = preferencePayload(current) + val encoded = encodePortalSyncPayload(payload) + check(encoded.rawBytes.size <= MAX_PROFILE_PREFERENCE_REQUEST_BYTES) { + "PROFILE_PREFERENCE_PLANNER_OVERSIZED_REQUEST" + } + val ledger = current.associate { it.key to it.sentLocalGeneration } + check(ledger.size == current.size && + ledger.size == payload.profilePreferenceSections.orEmpty().size + ) { + "PROFILE_PREFERENCE_LEDGER_CARDINALITY_MISMATCH" + } + chunks += ProfilePreferencePushChunk( + payload = payload, + ledger = ledger, + ) + current = mutableListOf() + } + + valid.forEach { mutation -> + val candidate = current + mutation + val bytes = encodePortalSyncPayload(preferencePayload(candidate)).rawBytes.size + if (bytes > MAX_PROFILE_PREFERENCE_REQUEST_BYTES) emit() + current += mutation + } + emit() + + check( + chunks.all { + encodePortalSyncPayload(it.payload).rawBytes.size <= + MAX_PROFILE_PREFERENCE_REQUEST_BYTES + }, + ) { + "PROFILE_PREFERENCE_PLANNER_OVERSIZED_REQUEST" + } + return ProfilePreferencePushPlan(chunks = chunks, unsyncable = issues) +} +``` + +The one-item full-request check occurs before greedy chunking, so `emit()` may rely on every first item fitting. Duplicate keys never enter a request or ledger, and every duplicate occurrence receives its own generation-specific `DUPLICATE_SECTION` issue while valid siblings continue. `preferencePayload` intentionally does not copy `profileId`, `profileName`, `allProfiles`, or any ordinary entity data from `basePayload`. + +- [ ] **Step 7: Run the focused adapter/planner tests and verify they pass** + +Run: + +```powershell +.\gradlew.bat :shared:testAndroidHostTest --tests "*PortalSyncAdapterProfilePreferencesTest*" --tests "*PortalPullAdapterProfilePreferencesTest*" --tests "*ProfilePreferenceSyncPlannerTest*" --tests "*ProfilePreferenceSyncDtosTest*" --tests "*PortalSyncAdapterTest*" --tests "*PortalPullAdapterTest*" --tests "*SqlDelightProfilePreferenceSyncRepositoryTest*" -Pskip.supabase.check=true +``` + +Expected: PASS, including Task 3 wire DTO guards, existing adapter behavior, and Task 4's duplicate validation at the persistence boundary. + +- [ ] **Step 8: Commit adapters and deterministic chunk planning** + +```powershell +git add shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/ProfilePreferenceSyncCodec.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/PortalWireJson.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/PortalSyncAdapter.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/PortalPullAdapter.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/ProfilePreferenceSyncPlanner.kt shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/PortalSyncAdapterProfilePreferencesTest.kt shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/PortalPullAdapterProfilePreferencesTest.kt shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/ProfilePreferenceSyncPlannerTest.kt +git commit -m "feat(sync): map and chunk profile preference sections" +``` + +--- + +### Task 6: Enforce Exact Transport Bytes and Capture Logical Multi-Request Tests + +**Files:** +- Modify: `gradle/libs.versions.toml` +- Modify: `shared/build.gradle.kts` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/PortalApiClient.kt` +- Modify: `shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakePortalApiClient.kt` +- Create: `shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/PortalApiClientProfilePreferenceLimitsTest.kt` +- Create: `shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakePortalApiClientTest.kt` +- Modify: `shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/ErrorClassificationTest.kt` + +**Interfaces:** +- Consumes: Task 5's `PortalWireJson`, `encodePortalSyncPayload`, exact `rawBytes`, spans, and size constants. +- Produces: transport defense in depth that encodes once per logical `pushPortalPayload` call, measures and sends that same never-mutated UTF-8 byte array on both the initial HTTP attempt and its optional 401 refresh retry, plus logical-call fakes for metadata-first/chunk/legacy-response orchestration. +- Boundary definition: Task 5 planning and Task 6 transport independently call the same deterministic encoder and therefore require byte-sequence equality, not the same `ByteArray` instance across layers. Task 6 remeasures immediately before transport so the bytes actually written are the bytes checked. + +- [ ] **Step 1: Add the version-aligned MockEngine test dependency** + +Add beside the existing Ktor aliases in `gradle/libs.versions.toml`: + +```toml +ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } +``` + +Add to `commonTest.dependencies` in `shared/build.gradle.kts`: + +```kotlin +implementation(libs.ktor.client.mock) +``` + +This is test-only infrastructure. Production continues to select the platform engine through the existing no-engine constructor path. + +- [ ] **Step 2: Write failing captured-engine and transport-limit tests** + +Create `PortalApiClientProfilePreferenceLimitsTest.kt`: + +```kotlin +package com.devil.phoenixproject.data.sync + +import com.devil.phoenixproject.testutil.readProjectFile +import com.russhwolf.settings.MapSettings +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.request.HttpRequestData +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.content.OutgoingContent +import io.ktor.http.headersOf +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +private const val LEGACY_PUSH_RESPONSE = + """{"syncTime":"2026-07-11T12:00:00Z"}""" +private val JSON_RESPONSE_HEADERS = headersOf( + HttpHeaders.ContentType, + ContentType.Application.Json.toString(), +) + +class PortalApiClientProfilePreferenceLimitsTest { + private fun authenticatedStorage( + accessToken: String = "old-token", + refreshToken: String = "refresh-token", + ) = PortalTokenStorage(MapSettings()).apply { + saveGoTrueAuth( + GoTrueAuthResponse( + accessToken = accessToken, + tokenType = "bearer", + expiresIn = 3600, + expiresAt = 4_102_444_800L, + refreshToken = refreshToken, + user = GoTrueUser(id = "user-a", email = "user@example.com"), + ), + ) + } + + private fun client(engine: MockEngine, storage: PortalTokenStorage = authenticatedStorage()) = + PortalApiClient( + SupabaseConfig("https://fake.supabase.co", "anon"), + storage, + httpClientEngine = engine, + ) + + private fun mutation(index: Int = 0, padding: Int = 0) = + PortalProfilePreferenceSectionMutationDto( + localProfileId = "profile-$index", + section = "RACK", + documentVersion = 1, + baseRevision = 0, + clientModifiedAt = "2026-07-11T12:00:00Z", + payload = buildJsonObject { put("padding", "x".repeat(padding)) }, + ) + + private fun preferencePayload( + mutations: List, + ) = PortalSyncPayload( + deviceId = "device", + platform = "android", + lastSync = 0, + profilePreferenceSections = mutations, + ) + + private fun payloadWithSectionBytes(targetBytes: Int): PortalSyncPayload { + val seed = preferencePayload(listOf(mutation())) + val encodedSeed = encodePortalSyncPayload(seed) + val fixedBytes = encodedSeed.preferenceElementByteCount( + encodedSeed.preferenceElementSpans.single(), + ) + val payload = preferencePayload( + listOf(mutation(padding = targetBytes - fixedBytes)), + ) + val encoded = encodePortalSyncPayload(payload) + check( + encoded.preferenceElementByteCount(encoded.preferenceElementSpans.single()) == + targetBytes, + ) + return payload + } + + private fun preferencePayloadWithRequestBytes(targetBytes: Int): PortalSyncPayload { + val seeds = List(3) { mutation(index = it) } + val fixedBytes = encodePortalSyncPayload(preferencePayload(seeds)).rawBytes.size + val paddingBytes = targetBytes - fixedBytes + require(paddingBytes >= 0) + val payload = preferencePayload( + seeds.mapIndexed { index, _ -> + val padding = paddingBytes / seeds.size + + if (index < paddingBytes % seeds.size) 1 else 0 + mutation(index = index, padding = padding) + }, + ) + check(encodePortalSyncPayload(payload).rawBytes.size == targetBytes) + return payload + } + + private fun payloadWithRequestBytes( + targetBytes: Int, + sections: List?, + ): PortalSyncPayload { + val seed = PortalSyncPayload( + deviceId = "", + lastSync = 0, + profilePreferenceSections = sections, + ) + val paddingBytes = targetBytes - encodePortalSyncPayload(seed).rawBytes.size + require(paddingBytes >= 0) + val payload = seed.copy(deviceId = "x".repeat(paddingBytes)) + check(encodePortalSyncPayload(payload).rawBytes.size == targetBytes) + return payload + } + + private fun requestBodyBytes(request: HttpRequestData): ByteArray = + assertIs(request.body).bytes() + + private fun effectiveContentTypes(request: HttpRequestData): List = + request.headers.getAll(HttpHeaders.ContentType).orEmpty().map(ContentType::parse) + + listOfNotNull(request.body.contentType) + + @Test + fun `transport writes the exact counted preference bytes with one JSON content type`() = + runTest { + val requests = mutableListOf() + val engine = MockEngine { request -> + requests += request + respond(LEGACY_PUSH_RESPONSE, HttpStatusCode.OK, JSON_RESPONSE_HEADERS) + } + val payload = preferencePayload(listOf(mutation(padding = 64))) + val expectedBytes = encodePortalSyncPayload(payload).rawBytes + + val result = client(engine).pushPortalPayload(payload) + + assertTrue(result.isSuccess) + val request = requests.single() + assertContentEquals(expectedBytes, requestBodyBytes(request)) + val contentTypes = effectiveContentTypes(request) + assertEquals(1, contentTypes.size) + assertTrue(contentTypes.single().match(ContentType.Application.Json)) + assertEquals("Bearer old-token", request.headers[HttpHeaders.Authorization]) + assertEquals("anon", request.headers["apikey"]) + } + + @Test + fun `shared ContentNegotiation decodes an ordinary legacy push response`() = runTest { + val engine = MockEngine { + respond(LEGACY_PUSH_RESPONSE, HttpStatusCode.OK, JSON_RESPONSE_HEADERS) + } + + val response = client(engine) + .pushPortalPayload(PortalSyncPayload(deviceId = "device", lastSync = 0)) + .getOrThrow() + + assertNull(response.profilePreferencesAccepted) + assertTrue(response.canonicalProfilePreferenceSections.isEmpty()) + assertTrue(response.profilePreferenceRejections.isEmpty()) + } + + @Test + fun `401 refresh retry writes byte-identical push bodies`() = runTest { + val pushRequests = mutableListOf() + val engine = MockEngine { request -> + when { + request.url.encodedPath.endsWith("/functions/v1/mobile-sync-push") -> { + pushRequests += request + if (pushRequests.size == 1) { + respond( + """{"error":"expired"}""", + HttpStatusCode.Unauthorized, + JSON_RESPONSE_HEADERS, + ) + } else { + respond(LEGACY_PUSH_RESPONSE, HttpStatusCode.OK, JSON_RESPONSE_HEADERS) + } + } + request.url.encodedPath.endsWith("/auth/v1/token") -> respond( + """ + { + "access_token":"new-token", + "token_type":"bearer", + "expires_in":3600, + "expires_at":4102444800, + "refresh_token":"new-refresh", + "user":{"id":"user-a","email":"user@example.com"} + } + """.trimIndent(), + HttpStatusCode.OK, + JSON_RESPONSE_HEADERS, + ) + else -> error("Unexpected MockEngine path") + } + } + val payload = preferencePayload(listOf(mutation(padding = 128))) + val expectedBytes = encodePortalSyncPayload(payload).rawBytes + + assertTrue(client(engine).pushPortalPayload(payload).isSuccess) + + assertEquals(2, pushRequests.size) + pushRequests.forEach { request -> + assertContentEquals(expectedBytes, requestBodyBytes(request)) + } + assertEquals( + listOf("Bearer old-token", "Bearer new-token"), + pushRequests.map { it.headers[HttpHeaders.Authorization] }, + ) + } + + @Test + fun `section cap accepts 262144 and rejects 262145 before HTTP`() = runTest { + var httpCalls = 0 + val engine = MockEngine { + httpCalls++ + respond(LEGACY_PUSH_RESPONSE, HttpStatusCode.OK, JSON_RESPONSE_HEADERS) + } + val client = client(engine) + + assertTrue( + client.pushPortalPayload( + payloadWithSectionBytes(MAX_PROFILE_PREFERENCE_SECTION_BYTES), + ).isSuccess, + ) + val error = client.pushPortalPayload( + payloadWithSectionBytes(MAX_PROFILE_PREFERENCE_SECTION_BYTES + 1), + ).exceptionOrNull() + + assertIs(error) + assertEquals(413, error.statusCode) + assertEquals("SECTION_TOO_LARGE: cap=262144", error.message) + assertEquals(1, httpCalls) + } + + @Test + fun `request cap accepts 524288 and rejects 524289 before HTTP`() = runTest { + var httpCalls = 0 + val engine = MockEngine { + httpCalls++ + respond(LEGACY_PUSH_RESPONSE, HttpStatusCode.OK, JSON_RESPONSE_HEADERS) + } + val client = client(engine) + val exact = preferencePayloadWithRequestBytes(MAX_PROFILE_PREFERENCE_REQUEST_BYTES) + val overflow = preferencePayloadWithRequestBytes( + MAX_PROFILE_PREFERENCE_REQUEST_BYTES + 1, + ) + assertTrue( + encodePortalSyncPayload(exact).preferenceElementSpans.all { span -> + encodePortalSyncPayload(exact).preferenceElementByteCount(span) <= + MAX_PROFILE_PREFERENCE_SECTION_BYTES + }, + ) + + assertTrue(client.pushPortalPayload(exact).isSuccess) + val error = client.pushPortalPayload(overflow).exceptionOrNull() + + assertIs(error) + assertEquals(413, error.statusCode) + assertEquals("REQUEST_TOO_LARGE: cap=524288", error.message) + assertEquals(1, httpCalls) + } + + @Test + fun `empty preference list uses 512 KiB while null preserves legacy ordinary cap`() = + runTest { + var httpCalls = 0 + val engine = MockEngine { + httpCalls++ + respond(LEGACY_PUSH_RESPONSE, HttpStatusCode.OK, JSON_RESPONSE_HEADERS) + } + val client = client(engine) + val target = MAX_PROFILE_PREFERENCE_REQUEST_BYTES + 1 + val ordinary = payloadWithRequestBytes(target, sections = null) + val presentEmpty = payloadWithRequestBytes(target, sections = emptyList()) + + assertTrue(client.pushPortalPayload(ordinary).isSuccess) + val error = client.pushPortalPayload(presentEmpty).exceptionOrNull() + + assertIs(error) + assertEquals(413, error.statusCode) + assertEquals("REQUEST_TOO_LARGE: cap=524288", error.message) + assertEquals(1, httpCalls) + } + + @Test + fun `legacy 9500000 byte cap is inclusive and overflow is 413`() = runTest { + var httpCalls = 0 + val engine = MockEngine { + httpCalls++ + respond(LEGACY_PUSH_RESPONSE, HttpStatusCode.OK, JSON_RESPONSE_HEADERS) + } + val client = client(engine) + val exact = payloadWithRequestBytes(SyncConfig.MAX_PAYLOAD_BYTES.toInt(), null) + val overflow = payloadWithRequestBytes( + SyncConfig.MAX_PAYLOAD_BYTES.toInt() + 1, + null, + ) + + assertTrue(client.pushPortalPayload(exact).isSuccess) + val error = client.pushPortalPayload(overflow).exceptionOrNull() + + assertIs(error) + assertEquals(413, error.statusCode) + assertEquals(1, httpCalls) + } + + @Test + fun `transport source encodes once per logical push and sends only raw bytes`() { + val source = assertNotNull( + readProjectFile( + "src/commonMain/kotlin/com/devil/phoenixproject/data/sync/PortalApiClient.kt", + ), + ) + + assertTrue("json(PortalWireJson)" in source) + assertEquals( + 1, + Regex("""encodePortalSyncPayload[(]payload[)]""").findAll(source).count(), + ) + assertTrue("setBody(encoded.rawBytes)" in source) + assertFalse(Regex("""setBody[(]encoded[.]raw[)]""").containsMatchIn(source)) + assertFalse(Regex("""private\s+val\s+json\s*=\s*Json""").containsMatchIn(source)) + } +} +``` + +The tests intentionally observe Ktor's final `HttpRequestData` rather than trusting source fragments alone. The expected bytes are independently produced by Task 5's deterministic encoder; the production call still creates its own `EncodedPortalSyncPayload` once and reuses that one array across its initial and retry lambdas. + +Add to `ErrorClassificationTest.kt`: + +```kotlin +@Test +fun status413IsPermanentAndNotRetryable() { + val result = classifyByStatusCode(413, "Payload too large") + + assertEquals(SyncErrorCategory.PERMANENT, result.category) + assertFalse(result.isRetryable) + assertEquals(413, result.statusCode) +} +``` + +- [ ] **Step 3: Write failing logical fake queue and history tests** + +Create `FakePortalApiClientTest.kt`: + +```kotlin +package com.devil.phoenixproject.testutil + +import com.devil.phoenixproject.data.sync.KnownEntityIds +import com.devil.phoenixproject.data.sync.PortalSyncPayload +import com.devil.phoenixproject.data.sync.PortalSyncPullResponse +import com.devil.phoenixproject.data.sync.PortalSyncPushResponse +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest + +class FakePortalApiClientTest { + private fun pushResponse(time: String) = + Result.success(PortalSyncPushResponse(syncTime = time)) + + @Test + fun `configured push queue preserves exact history and fails when exhausted`() = runTest { + val fake = FakePortalApiClient() + val first = PortalSyncPayload(deviceId = "first", lastSync = 0) + val second = PortalSyncPayload(deviceId = "second", lastSync = 0) + fake.pushResultsQueue = mutableListOf( + pushResponse("2026-07-11T12:00:00Z"), + pushResponse("2026-07-11T12:00:01Z"), + ) + + assertTrue(fake.pushPortalPayload(first).isSuccess) + assertTrue(fake.pushPortalPayload(second).isSuccess) + + assertEquals(listOf(first, second), fake.pushPayloads) + assertEquals(second, fake.lastPushPayload) + assertEquals(2, fake.pushCallCount) + assertTrue(fake.pushResultsQueue.orEmpty().isEmpty()) + assertFailsWith { + fake.pushPortalPayload(PortalSyncPayload(deviceId = "unexpected", lastSync = 0)) + } + } + + @Test + fun `configured pull queue preserves profile history and fails when exhausted`() = runTest { + val fake = FakePortalApiClient() + fake.pullResultsQueue = mutableListOf( + Result.success(PortalSyncPullResponse(syncTime = 1)), + Result.success(PortalSyncPullResponse(syncTime = 2)), + ) + + listOf("profile-a", "profile-b").forEach { profileId -> + assertTrue( + fake.pullPortalPayload( + knownEntityIds = KnownEntityIds(), + deviceId = "device", + profileId = profileId, + cursor = null, + pageSize = 100, + ).isSuccess, + ) + } + + assertEquals(listOf("profile-a", "profile-b"), fake.pullCallProfileIds) + assertEquals("profile-b", fake.lastPullProfileId) + assertEquals(2, fake.pullCallCount) + assertTrue(fake.pullResultsQueue.orEmpty().isEmpty()) + assertFailsWith { + fake.pullPortalPayload( + KnownEntityIds(), + "device", + "unexpected", + null, + 100, + ) + } + } +} +``` + +A null queue means “always use the default result.” A non-null queue means “these are the complete expected logical calls”; exhaustion is a test failure, never an implicit fallback. Captures occur before dequeue so an unexpected attempted call remains inspectable. + +- [ ] **Step 4: Run the tests and verify the red state** + +Run: + +```powershell +.\gradlew.bat :shared:testAndroidHostTest --tests "com.devil.phoenixproject.data.sync.PortalApiClientProfilePreferenceLimitsTest" --tests "com.devil.phoenixproject.testutil.FakePortalApiClientTest" --tests "com.devil.phoenixproject.data.sync.ErrorClassificationTest" -Pskip.supabase.check=true +``` + +Expected: FAIL because `PortalApiClient` has no injectable engine, still serializes a `String`, has only the legacy overall guard, classifies 413 as transient, and the fake lacks strict queues/history. + +- [ ] **Step 5: Reuse `PortalWireJson` through an injectable, production-identical client configuration** + +Remove the `kotlinx.serialization.json.Json` import and `PortalApiClient`'s private `Json` construction. Add `HttpClientConfig` and `HttpClientEngine` imports, move the existing timeout/default-request configuration into one shared builder, and use it for both the platform-default production engine and an injected test engine: + +```kotlin +private fun HttpClientConfig<*>.configurePortalHttpClient() { + install(ContentNegotiation) { + json(PortalWireJson) + } + install(HttpTimeout) { + requestTimeoutMillis = 60_000 + connectTimeoutMillis = 10_000 + socketTimeoutMillis = 60_000 + } + defaultRequest { + contentType(ContentType.Application.Json) + } +} + +private fun createPortalHttpClient(engine: HttpClientEngine?): HttpClient = + if (engine == null) { + HttpClient { configurePortalHttpClient() } + } else { + HttpClient(engine) { configurePortalHttpClient() } + } + +open class PortalApiClient( + private val supabaseConfig: SupabaseConfig, + private val tokenStorage: PortalTokenStorage, + httpClientEngine: HttpClientEngine? = null, +) { + private val refreshMutex = Mutex() + private val httpClient = createPortalHttpClient(httpClientEngine) +``` + +These are the class's opening fields; retain every existing auth/sync method below them and remove the old `refreshMutex`/private-`Json`/`HttpClient` declarations. The defaulted engine parameter preserves every production/Koin/fake call site. Tests inject `MockEngine` while exercising the exact same ContentNegotiation, timeout, and default-header configuration as production. + +Inside `pushPortalPayload`, call `encodePortalSyncPayload` exactly once and use its `rawBytes` for every limit and for the request body. Before the existing overall byte check, add: + +```kotlin +val encoded = encodePortalSyncPayload(payload) +if (payload.profilePreferenceSections != null) { + encoded.preferenceElementSpans.forEach { span -> + if (encoded.preferenceElementByteCount(span) > MAX_PROFILE_PREFERENCE_SECTION_BYTES) { + return Result.failure( + PortalApiException( + "SECTION_TOO_LARGE: cap=$MAX_PROFILE_PREFERENCE_SECTION_BYTES", + statusCode = 413, + ), + ) + } + } + if (encoded.rawBytes.size > MAX_PROFILE_PREFERENCE_REQUEST_BYTES) { + return Result.failure( + PortalApiException( + "REQUEST_TOO_LARGE: cap=$MAX_PROFILE_PREFERENCE_REQUEST_BYTES", + statusCode = 413, + ), + ) + } +} +``` + +Retain the existing `MAX_PAYLOAD_BYTES` check against `encoded.rawBytes` after this block, but make its deterministic local overflow a 413 as well: + +```kotlin +if (encoded.rawBytes.size > SyncConfig.MAX_PAYLOAD_BYTES) { + return Result.failure( + PortalApiException( + "Push payload is ${encoded.rawBytes.size} bytes; " + + "cap is ${SyncConfig.MAX_PAYLOAD_BYTES} bytes. Caller must split.", + statusCode = 413, + ), + ) +} +``` + +Replace every remaining `serialized` reference in this method and send the exact already-counted bytes: + +```kotlin +httpClient.post("${supabaseConfig.url}/functions/v1/mobile-sync-push") { + bearerAuth(token) + header("apikey", supabaseConfig.anonKey) + contentType(ContentType.Application.Json) + setBody(encoded.rawBytes) +} +``` + +`authenticatedRequest` may invoke this HTTP lambda twice around one 401 refresh, but `encoded` is created outside the lambda. Both attempts therefore receive the same array without another serializer call. Do not mutate `encoded.rawBytes`, call `encodeToByteArray` again, or use the encoded `String` as the body: the same byte array is measured by the per-section/full-request/overall guards and written to HTTP. A non-null preference field, including an empty array, always applies 512 KiB to the complete raw `PortalSyncPayload`; without that field only the inclusive 9,500,000-byte overall cap applies. Preference-specific errors are fixed categories plus the public cap only; they never include profile identity, section values, or measured user-data sizes. + +Finally, add 413 to the permanent client-error branch in `classifyByStatusCode`: + +```kotlin +400, 404, 413 -> ClassifiedSyncError( + category = SyncErrorCategory.PERMANENT, + message = message, + statusCode = statusCode, + isRetryable = false, + cause = cause, +) +``` + +This classification applies when a local or remote 413 escapes to the sync trigger; byte-identical retries cannot repair a deterministic cap violation. + +- [ ] **Step 6: Extend the fake for strict logical multi-request orchestration** + +Add these captures to `FakePortalApiClient`: + +```kotlin +val pushPayloads: MutableList = mutableListOf() +var pushResultsQueue: MutableList>? = null +var lastPullProfileId: String? = null +val pullCallProfileIds: MutableList = mutableListOf() + +private fun MutableList>.removeExpectedResult(): Result { + check(isNotEmpty()) { "FAKE_PORTAL_RESULT_QUEUE_EXHAUSTED" } + return removeAt(0) +} +``` + +Update the overrides: + +```kotlin +override suspend fun pushPortalPayload(payload: PortalSyncPayload): Result { + pushCallCount++ + lastPushPayload = payload + pushPayloads += payload + return pushResultsQueue?.removeExpectedResult() ?: pushResult +} + +override suspend fun pullPortalPayload( + knownEntityIds: KnownEntityIds, + deviceId: String, + profileId: String?, + cursor: String?, + pageSize: Int?, +): Result { + pullCallCount++ + lastPullKnownEntityIds = knownEntityIds + lastPullDeviceId = deviceId + lastPullProfileId = profileId + pullCallProfileIds += profileId + lastPullCursor = cursor + lastPullPageSize = pageSize + pullCallCursors += cursor + pullCallTimestampsMs += pullTimestampSourceMs() + return pullResultsQueue?.removeExpectedResult() ?: pullResult +} +``` + +Do not model `PortalApiClient`'s internal 401 HTTP retry as a second fake call: this fake operates at the logical API boundary. The captured-engine tests own physical-attempt behavior. + +- [ ] **Step 7: Run the transport, fake, planner, token, and ordinary-push regressions** + +Run: + +```powershell +.\gradlew.bat :shared:testAndroidHostTest --tests "*PortalApiClientProfilePreferenceLimitsTest*" --tests "*FakePortalApiClientTest*" --tests "*ErrorClassificationTest*" --tests "*PortalTokenRefreshTest*" --tests "*ProfilePreferenceSyncPlannerTest*" --tests "*ProfilePreferenceSyncDtosTest*" --tests "*PortalPushLimitsTest*" --tests "*PortalPullPaginationTest*" --tests "*SyncManagerTest*" -Pskip.supabase.check=true +``` + +Expected: PASS, including exact outgoing bytes/headers, ordinary response decoding, byte-identical 401 retry, all inclusive caps, null-versus-empty preference behavior, permanent 413 classification, strict fake queues, and unchanged ordinary batching. + +- [ ] **Step 8: Commit transport guards and test captures** + +```powershell +git add gradle/libs.versions.toml shared/build.gradle.kts shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/PortalApiClient.kt shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakePortalApiClient.kt shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/PortalApiClientProfilePreferenceLimitsTest.kt shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakePortalApiClientTest.kt shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/ErrorClassificationTest.kt +git commit -m "feat(sync): enforce profile preference payload limits" +``` + +--- + +### Task 7: Send Metadata First and Apply Preference Acknowledgements Safely + +**Files:** +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/SyncManager.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/di/SyncModule.kt` +- Create: `shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeProfilePreferenceSyncRepository.kt` +- Create: `shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/SyncManagerProfilePreferencesTest.kt` +- Modify: `shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/PortalPushLimitsTest.kt` +- Modify: `shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/SyncManagerTest.kt` +- Modify: `shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/PortalTokenRefreshTest.kt` +- Modify: `shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/PortalPullPaginationTest.kt` +- Modify: `shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/sync/SqlDelightProfilePreferenceSyncRepositoryTest.kt` +- Test: `shared/src/androidHostTest/kotlin/com/devil/phoenixproject/di/KoinModuleVerifyTest.kt` + +**Interfaces:** +- Consumes: Task 4's internal persistence adapter, the data-foundation migration gate, Task 5's planner, and Task 6's transport/fakes. +- Produces: exact-parent metadata-first minimal preference-only pushes, race-safe canonical/rejection application, non-cancellation preference-local failure isolation that preserves ordinary acknowledgements, and reusable sanitized diagnostic-line helpers that never expose raw profile ids, remote section strings, issue keys, exception messages, or payload fragments. + +- [ ] **Step 1: Add the focused fake sync repository with captures** + +Create `FakeProfilePreferenceSyncRepository.kt`: + +```kotlin +internal class FakeProfilePreferenceSyncRepository : ProfilePreferenceSyncRepository { + var dirtySnapshot = ProfilePreferenceDirtySnapshot(emptyList(), emptyList()) + var snapshotCallCount = 0 + var snapshotFailure: Exception? = null + var applyPushFailure: Exception? = null + var applyPullFailure: Exception? = null + var knownProfileIds: Set = setOf("profile-a") + var onApplyPulledSections: (() -> Unit)? = null + var pullApplyCallCount = 0 + val appliedPushOutcomes = mutableListOf>() + val pulledSectionCalls = mutableListOf>() + val appliedPulledSections = mutableListOf>() + + override suspend fun snapshotDirtySections(): ProfilePreferenceDirtySnapshot { + snapshotCallCount++ + snapshotFailure?.let { throw it } + return dirtySnapshot + } + + override suspend fun applyPushOutcomes( + outcomes: List, + ): ProfilePreferenceSyncApplyReport { + applyPushFailure?.let { throw it } + appliedPushOutcomes += outcomes + return ProfilePreferenceSyncApplyReport(applied = outcomes.size) + } + + override suspend fun applyPulledSections( + sections: List, + ): ProfilePreferenceSyncApplyReport { + pullApplyCallCount++ + pulledSectionCalls += sections + applyPullFailure?.let { throw it } + onApplyPulledSections?.invoke() + val known = sections.filter { it.key.localProfileId in knownProfileIds } + appliedPulledSections += known + return ProfilePreferenceSyncApplyReport( + applied = known.size, + ignoredUnknownProfile = sections.size - known.size, + ) + } +} +``` + +- [ ] **Step 2: Write failing exact-parent metadata, readiness, and legacy-backend tests** + +Create `SyncManagerProfilePreferencesTest.kt` with a `Harness` that owns `FakePortalApiClient`, `PortalTokenStorage(MapSettings())`, the existing sync/gamification/metric/profile/activity fakes, and `FakeProfilePreferenceSyncRepository`. The harness must seed the exact profile used by every nominal preference fixture before a sync; otherwise `ensureDefaultProfile()` would send only `default` metadata while a `profile-a` preference test falsely passes on call order alone: + +```kotlin +init { + profileRepository.setActiveProfileForTest("profile-a") + tokenStorage.saveAuth( + PortalAuthResponse( + token = "token", + user = PortalUser("user", "u@example.com", null, false), + ), + ) +} +``` + +Include these assertions: + +```kotlin +@Test +fun `ordinary metadata push precedes preference-only chunks`() = runTest { + harness.preferenceSyncRepository.dirtySnapshot = ProfilePreferenceDirtySnapshot( + valid = listOf(coreSection(generation = 1)), + unsyncable = emptyList(), + ) + harness.api.pushResultsQueue = mutableListOf( + successResponse(), + successResponse( + profilePreferencesAccepted = true, + canonicalProfilePreferenceSections = listOf(coreCanonical(revision = 1)), + ), + ) + + assertTrue(harness.manager(migrationReady = { true }).sync().isSuccess) + + assertEquals(2, harness.api.pushPayloads.size) + val metadataPayload = harness.api.pushPayloads[0] + val metadataProfileIds = metadataPayload.allProfiles.orEmpty().mapTo(linkedSetOf()) { it.id } + assertTrue("profile-a" in metadataProfileIds) + assertNull(metadataPayload.profilePreferenceSections) + val preferencePayload = harness.api.pushPayloads[1] + assertNull(preferencePayload.profileId) + assertNull(preferencePayload.profileName) + assertNull(preferencePayload.allProfiles) + assertTrue(preferencePayload.sessions.isEmpty()) + assertTrue(preferencePayload.routines.isEmpty()) + assertTrue(preferencePayload.personalRecords.isEmpty()) + assertEquals(1, preferencePayload.profilePreferenceSections?.size) + val preferenceProfileIds = preferencePayload.profilePreferenceSections.orEmpty() + .mapTo(linkedSetOf()) { it.localProfileId } + assertEquals(setOf("profile-a"), preferenceProfileIds) + assertTrue(preferenceProfileIds.all { it in metadataProfileIds }) +} + +@Test +fun `migration readiness is read dynamically and only Ready enables preferences`() = runTest { + harness.preferenceSyncRepository.dirtySnapshot = ProfilePreferenceDirtySnapshot( + valid = listOf(coreSection(generation = 1)), + unsyncable = emptyList(), + ) + harness.api.pushResultsQueue = mutableListOf( + successResponse(), + successResponse(), + successResponse( + profilePreferencesAccepted = true, + canonicalProfilePreferenceSections = listOf(coreCanonical(revision = 1)), + ), + ) + var migrationState: RequiredMigrationState = RequiredMigrationState.NotStarted + val manager = harness.manager( + migrationReady = { migrationState is RequiredMigrationState.Ready }, + ) + + assertTrue(manager.sync().isSuccess) + + assertEquals(1, harness.api.pushPayloads.size) + assertEquals(0, harness.preferenceSyncRepository.snapshotCallCount) + assertTrue(harness.preferenceSyncRepository.appliedPushOutcomes.isEmpty()) + + migrationState = RequiredMigrationState.Ready + assertTrue(manager.sync().isSuccess) + + assertEquals(3, harness.api.pushPayloads.size) + assertEquals(1, harness.preferenceSyncRepository.snapshotCallCount) + assertEquals( + listOf("profile-a"), + harness.api.pushPayloads.last().profilePreferenceSections?.map { it.localProfileId }, + ) +} + +@Test +fun `snapshot profiles absent from the sent metadata set are deferred`() = runTest { + val newProfileSection = workoutSection(generation = 2).copy( + key = ProfilePreferenceSectionKey( + "profile-created-during-ordinary-push", + ProfilePreferenceSectionName.WORKOUT, + ), + ) + harness.preferenceSyncRepository.dirtySnapshot = ProfilePreferenceDirtySnapshot( + valid = listOf(coreSection(generation = 1), newProfileSection), + unsyncable = emptyList(), + ) + harness.api.pushResultsQueue = mutableListOf( + successResponse(), + successResponse( + profilePreferencesAccepted = true, + canonicalProfilePreferenceSections = listOf(coreCanonical(revision = 1)), + ), + ) + + assertTrue(harness.manager(migrationReady = { true }).sync().isSuccess) + + val sentMetadataProfileIds = harness.api.pushPayloads[0].allProfiles.orEmpty() + .mapTo(linkedSetOf()) { it.id } + val sentPreferenceProfileIds = harness.api.pushPayloads[1].profilePreferenceSections.orEmpty() + .mapTo(linkedSetOf()) { it.localProfileId } + assertTrue("profile-a" in sentMetadataProfileIds) + assertTrue("profile-created-during-ordinary-push" !in sentMetadataProfileIds) + assertEquals(setOf("profile-a"), sentPreferenceProfileIds) + assertTrue(sentPreferenceProfileIds.all { it in sentMetadataProfileIds }) + assertTrue( + harness.preferenceSyncRepository.appliedPushOutcomes + .flatten() + .none { it.key == newProfileSection.key }, + ) +} + +@Test +fun `ordinary push failure never snapshots or sends preferences`() = runTest { + harness.preferenceSyncRepository.dirtySnapshot = ProfilePreferenceDirtySnapshot( + valid = listOf(coreSection(generation = 1)), + unsyncable = emptyList(), + ) + harness.api.pushResultsQueue = mutableListOf( + Result.failure(PortalApiException("ordinary sentinel", statusCode = 503)), + ) + + assertTrue(harness.manager(migrationReady = { true }).sync().isFailure) + + assertEquals(1, harness.api.pushPayloads.size) + assertEquals(0, harness.preferenceSyncRepository.snapshotCallCount) + assertTrue(harness.api.pushPayloads.none { it.profilePreferenceSections != null }) +} + +@Test +fun `legacy backend leaves every preference section dirty`() = runTest { + harness.preferenceSyncRepository.dirtySnapshot = ProfilePreferenceDirtySnapshot( + valid = listOf(coreSection(generation = 1), workoutSection(generation = 2)), + unsyncable = emptyList(), + ) + harness.api.pushResultsQueue = mutableListOf(successResponse(), successResponse()) + + assertTrue(harness.manager(migrationReady = { true }).sync().isSuccess) + + assertEquals(2, harness.api.pushPayloads.size) + assertTrue(harness.preferenceSyncRepository.appliedPushOutcomes.isEmpty()) +} + +@Test +fun `strict legacy 400 leaves preferences dirty and preserves ordinary acknowledgement`() = runTest { + val ordinary = ordinarySession() + harness.syncRepository.workoutSessionsToReturn = listOf(ordinary) + harness.preferenceSyncRepository.dirtySnapshot = ProfilePreferenceDirtySnapshot( + valid = listOf(coreSection(generation = 1)), + unsyncable = emptyList(), + ) + harness.api.pushResultsQueue = mutableListOf( + successResponse(), + Result.failure(PortalApiException("legacy body sentinel", statusCode = 400)), + ) + + assertTrue(harness.manager(migrationReady = { true }).sync().isSuccess) + + assertEquals(2, harness.api.pushPayloads.size) + assertTrue(harness.preferenceSyncRepository.appliedPushOutcomes.isEmpty()) + assertEquals(listOf(ordinary.id), harness.syncRepository.updateSessionTimestampCalls) +} +``` + +Use this exact constructor boundary in `Harness.manager`. The readiness lambda is retained, not evaluated while constructing the manager, so one singleton observes `NotStarted`/`Applying`/`Failed` as false and a later `Ready` as true: + +```kotlin +fun manager( + migrationReady: () -> Boolean = { true }, + rateLimiter: ClientRateLimiter = ClientRateLimiter(), +) = SyncManager( + apiClient = api, + tokenStorage = tokenStorage, + syncRepository = syncRepository, + gamificationRepository = gamificationRepository, + repMetricRepository = repMetricRepository, + userProfileRepository = profileRepository, + profilePreferenceSyncRepository = preferenceSyncRepository, + externalActivityRepository = externalActivityRepository, + velocityOneRepMaxRepository = velocityOneRepMaxRepository, + rateLimiter = rateLimiter, + isProfilePreferenceMigrationReady = migrationReady, +) + +private fun coreSection(generation: Long) = ProfilePreferenceSectionSyncDto( + key = ProfilePreferenceSectionKey("profile-a", ProfilePreferenceSectionName.CORE), + documentVersion = 1, + baseRevision = 0, + clientModifiedAtEpochMs = 1_783_771_200_000L, + localGeneration = generation, + payload = buildJsonObject { + put("bodyWeightKg", 80.0) + put("weightUnit", "KG") + put("weightIncrement", 0.5) + }, +) + +private fun workoutSection(generation: Long) = ProfilePreferenceSectionSyncDto( + key = ProfilePreferenceSectionKey("profile-a", ProfilePreferenceSectionName.WORKOUT), + documentVersion = 1, + baseRevision = 0, + clientModifiedAtEpochMs = 1_783_771_200_000L, + localGeneration = generation, + payload = ProfilePreferenceSyncCodec().workoutPayload(WorkoutPreferences()), +) + +private fun coreCanonical(revision: Long) = PortalProfilePreferenceSectionCanonicalDto( + localProfileId = "profile-a", + section = "CORE", + documentVersion = 1, + serverRevision = revision, + serverUpdatedAt = "2026-07-11T12:00:00Z", + payload = coreSection(generation = 1).payload, +) + +private fun workoutCanonical(revision: Long) = PortalProfilePreferenceSectionCanonicalDto( + localProfileId = "profile-a", + section = "WORKOUT", + documentVersion = 1, + serverRevision = revision, + serverUpdatedAt = "2026-07-11T12:00:00Z", + payload = workoutSection(generation = 1).payload, +) + +private fun successResponse( + profilePreferencesAccepted: Boolean? = null, + canonicalProfilePreferenceSections: List = emptyList(), + profilePreferenceRejections: List = emptyList(), +) = PortalSyncPushResponse( + syncTime = "2026-07-11T12:00:00Z", + profilePreferencesAccepted = profilePreferencesAccepted, + canonicalProfilePreferenceSections = canonicalProfilePreferenceSections, + profilePreferenceRejections = profilePreferenceRejections, +) + +private fun ordinarySession() = WorkoutSession( + id = "ordinary-session", + timestamp = 1_783_771_200_000L, + mode = "OldSchool", + reps = 10, + weightPerCableKg = 20f, + totalReps = 10, + exerciseId = "ordinary-exercise", + exerciseName = "Ordinary Exercise", + routineSessionId = null, + profileId = "profile-a", +) + +private fun multiChunkSnapshot(): ProfilePreferenceDirtySnapshot { + fun largeBoundarySection( + section: ProfilePreferenceSectionName, + generation: Long, + ) = ProfilePreferenceSectionSyncDto( + key = ProfilePreferenceSectionKey("profile-a", section), + documentVersion = 1, + baseRevision = 0, + clientModifiedAtEpochMs = 1_783_771_200_000L, + localGeneration = generation, + // This focused orchestration fixture is wire-safe and below the 256 KiB + // element cap. Three such unique sections exceed one 512 KiB request. + payload = buildJsonObject { put("padding", "x".repeat(180_000)) }, + ) + + return ProfilePreferenceDirtySnapshot( + valid = listOf( + coreSection(generation = 1), + largeBoundarySection(ProfilePreferenceSectionName.RACK, generation = 2), + largeBoundarySection(ProfilePreferenceSectionName.WORKOUT, generation = 3), + largeBoundarySection(ProfilePreferenceSectionName.LED, generation = 4), + ), + unsyncable = emptyList(), + ) +} +``` + +- [ ] **Step 3: Write failing acknowledgement and in-flight generation tests** + +Add tests that verify: + +```kotlin +@Test +fun `accepted canonical carries sent generation into repository outcome`() = runTest { + harness.preferenceSyncRepository.dirtySnapshot = ProfilePreferenceDirtySnapshot( + valid = listOf(coreSection(generation = 8)), + unsyncable = emptyList(), + ) + harness.api.pushResultsQueue = mutableListOf( + successResponse(), + successResponse( + profilePreferencesAccepted = true, + canonicalProfilePreferenceSections = listOf(coreCanonical(revision = 4)), + ), + ) + + harness.manager(migrationReady = { true }).sync() + + val outcome = harness.preferenceSyncRepository.appliedPushOutcomes.single().single() + assertEquals(8L, outcome.sentLocalGeneration) + assertEquals(4L, outcome.serverRevision) + assertNull(outcome.rejectionReason) +} + +@Test +fun `canonical conflict is applied only through generation ledger`() = runTest { + harness.preferenceSyncRepository.dirtySnapshot = ProfilePreferenceDirtySnapshot( + valid = listOf(coreSection(generation = 11)), + unsyncable = emptyList(), + ) + harness.api.pushResultsQueue = mutableListOf( + successResponse(), + successResponse( + profilePreferencesAccepted = true, + profilePreferenceRejections = listOf( + ProfilePreferenceSectionRejectionDto( + localProfileId = "profile-a", + section = "CORE", + serverRevision = 6, + reason = "REVISION_CONFLICT", + canonicalSection = coreCanonical(revision = 6), + ), + ), + ), + ) + + harness.manager(migrationReady = { true }).sync() + + val outcome = harness.preferenceSyncRepository.appliedPushOutcomes.single().single() + assertEquals(11L, outcome.sentLocalGeneration) + assertEquals("REVISION_CONFLICT", outcome.rejectionReason) + assertEquals(6L, outcome.canonical?.serverRevision) +} + +@Test +fun `first preference chunk failure is nonfatal and attempts no later chunk`() = runTest { + val ordinary = ordinarySession() + harness.syncRepository.workoutSessionsToReturn = listOf(ordinary) + harness.preferenceSyncRepository.dirtySnapshot = multiChunkSnapshot() + harness.api.pushResultsQueue = mutableListOf( + successResponse(), + Result.failure(PortalApiException("first chunk sentinel", statusCode = 503)), + ) + + assertTrue(harness.manager(migrationReady = { true }).sync().isSuccess) + + assertEquals(2, harness.api.pushPayloads.size) + assertEquals(1, harness.api.pushPayloads.count { it.profilePreferenceSections != null }) + assertTrue(harness.preferenceSyncRepository.appliedPushOutcomes.isEmpty()) + assertEquals(listOf(ordinary.id), harness.syncRepository.updateSessionTimestampCalls) +} + +@Test +fun `later preference chunk failure keeps earlier outcome and ordinary acknowledgement`() = runTest { + val ordinary = ordinarySession() + harness.syncRepository.workoutSessionsToReturn = listOf(ordinary) + harness.preferenceSyncRepository.dirtySnapshot = multiChunkSnapshot() + harness.api.pushResultsQueue = mutableListOf( + successResponse(), + successResponse( + profilePreferencesAccepted = true, + canonicalProfilePreferenceSections = listOf(coreCanonical(revision = 1)), + ), + Result.failure(PortalApiException("later chunk sentinel", statusCode = 503)), + ) + + assertTrue(harness.manager(migrationReady = { true }).sync().isSuccess) + + assertEquals(3, harness.api.pushPayloads.size) + assertEquals( + listOf(ProfilePreferenceSectionName.CORE), + harness.preferenceSyncRepository.appliedPushOutcomes + .flatten() + .map { it.key.section }, + ) + assertEquals(listOf(ordinary.id), harness.syncRepository.updateSessionTimestampCalls) +} + +@Test +fun `lost preference response applies nothing and unchanged retry converges by conflict`() = runTest { + harness.preferenceSyncRepository.dirtySnapshot = ProfilePreferenceDirtySnapshot( + valid = listOf(coreSection(generation = 7)), + unsyncable = emptyList(), + ) + val conflict = ProfilePreferenceSectionRejectionDto( + localProfileId = "profile-a", + section = "CORE", + serverRevision = 1, + reason = "REVISION_CONFLICT", + canonicalSection = coreCanonical(revision = 1), + ) + harness.api.pushResultsQueue = mutableListOf( + successResponse(), + Result.failure(PortalApiException("lost response sentinel", statusCode = 503)), + successResponse(), + successResponse( + profilePreferencesAccepted = true, + profilePreferenceRejections = listOf(conflict), + ), + ) + val manager = harness.manager(migrationReady = { true }) + + assertTrue(manager.sync().isSuccess) + assertTrue(harness.preferenceSyncRepository.appliedPushOutcomes.isEmpty()) + + assertTrue(manager.sync().isSuccess) + val outcome = harness.preferenceSyncRepository.appliedPushOutcomes.single().single() + assertEquals(7L, outcome.sentLocalGeneration) + assertEquals("REVISION_CONFLICT", outcome.rejectionReason) + assertEquals(1L, outcome.canonical?.serverRevision) +} + +@Test +fun `preference local failures never erase the successful ordinary acknowledgement`() = runTest { + val ordinary = ordinarySession() + harness.syncRepository.workoutSessionsToReturn = listOf(ordinary) + harness.preferenceSyncRepository.snapshotFailure = IllegalStateException("snapshot sentinel") + harness.api.pushResultsQueue = mutableListOf(successResponse()) + + assertTrue(harness.manager(migrationReady = { true }).sync().isSuccess) + assertEquals(listOf(ordinary.id), harness.syncRepository.updateSessionTimestampCalls) + assertEquals(1, harness.api.pushPayloads.size) +} + +@Test +fun `outcome apply failure is isolated after a complete preference response`() = runTest { + val ordinary = ordinarySession() + harness.syncRepository.workoutSessionsToReturn = listOf(ordinary) + harness.preferenceSyncRepository.dirtySnapshot = ProfilePreferenceDirtySnapshot( + valid = listOf(coreSection(generation = 1)), + unsyncable = emptyList(), + ) + harness.preferenceSyncRepository.applyPushFailure = IllegalStateException("apply sentinel") + harness.api.pushResultsQueue = mutableListOf( + successResponse(), + successResponse( + profilePreferencesAccepted = true, + canonicalProfilePreferenceSections = listOf(coreCanonical(revision = 1)), + ), + ) + + assertTrue(harness.manager(migrationReady = { true }).sync().isSuccess) + assertEquals(listOf(ordinary.id), harness.syncRepository.updateSessionTimestampCalls) + assertTrue(harness.preferenceSyncRepository.appliedPushOutcomes.isEmpty()) +} + +@Test +fun `duplicate response cardinality invalidates only that ledger key`() { + val coreKey = coreSection(generation = 11).key + val workoutKey = workoutSection(generation = 12).key + val ledger = mapOf(coreKey to 11L, workoutKey to 12L) + val coreRejection = ProfilePreferenceSectionRejectionDto( + localProfileId = "profile-a", + section = "CORE", + serverRevision = 7, + reason = "REVISION_CONFLICT", + canonicalSection = coreCanonical(revision = 7), + ) + val cases = listOf( + successResponse( + profilePreferencesAccepted = true, + canonicalProfilePreferenceSections = listOf( + coreCanonical(7), + workoutCanonical(4), + ), + profilePreferenceRejections = listOf(coreRejection), + ), + successResponse( + profilePreferencesAccepted = true, + canonicalProfilePreferenceSections = listOf( + coreCanonical(7), + coreCanonical(8), + workoutCanonical(4), + ), + ), + successResponse( + profilePreferencesAccepted = true, + canonicalProfilePreferenceSections = listOf(workoutCanonical(4)), + profilePreferenceRejections = listOf(coreRejection, coreRejection), + ), + ) + + cases.forEach { response -> + val outcomes = buildProfilePreferencePushOutcomes(response, ledger) + assertEquals(listOf(workoutKey), outcomes.map { it.key }) + assertEquals(12L, outcomes.single().sentLocalGeneration) + assertEquals(4L, outcomes.single().serverRevision) + } +} + +@Test +fun `rejection canonical key or revision mismatch invalidates only that ledger key`() { + val coreKey = coreSection(generation = 11).key + val workoutKey = workoutSection(generation = 12).key + val mismatchedRejections = listOf( + ProfilePreferenceSectionRejectionDto( + localProfileId = "profile-a", + section = "CORE", + serverRevision = 8, + reason = "REVISION_CONFLICT", + canonicalSection = coreCanonical(revision = 7), + ), + ProfilePreferenceSectionRejectionDto( + localProfileId = "profile-a", + section = "CORE", + serverRevision = 8, + reason = "REVISION_CONFLICT", + canonicalSection = workoutCanonical(revision = 8), + ), + ) + + mismatchedRejections.forEach { rejection -> + val outcomes = buildProfilePreferencePushOutcomes( + successResponse( + profilePreferencesAccepted = true, + canonicalProfilePreferenceSections = listOf(workoutCanonical(4)), + profilePreferenceRejections = listOf(rejection), + ), + ledger = mapOf(coreKey to 11L, workoutKey to 12L), + ) + + assertEquals(listOf(workoutKey), outcomes.map { it.key }) + assertEquals(12L, outcomes.single().sentLocalGeneration) + assertEquals(4L, outcomes.single().serverRevision) + } +} + +@Test +fun `missing unknown and malformed response entries leave only their ledger keys dirty`() { + val coreKey = coreSection(generation = 11).key + val workoutKey = workoutSection(generation = 12).key + val ledger = mapOf(coreKey to 11L, workoutKey to 12L) + val workout = workoutCanonical(revision = 4) + val cases = listOf( + // CORE is missing. A response for a key outside the ledger cannot fill or poison it. + successResponse( + profilePreferencesAccepted = true, + canonicalProfilePreferenceSections = listOf( + workout, + coreCanonical(9).copy(localProfileId = "remote-only"), + ), + ), + // Count the malformed known-key rejection before reason validation. It poisons the + // otherwise-valid CORE canonical, while WORKOUT remains independently applicable. + successResponse( + profilePreferencesAccepted = true, + canonicalProfilePreferenceSections = listOf(coreCanonical(7), workout), + profilePreferenceRejections = listOf( + ProfilePreferenceSectionRejectionDto( + localProfileId = "profile-a", + section = "CORE", + serverRevision = 7, + reason = "UNSAFE_REMOTE_REASON_SENTINEL", + canonicalSection = coreCanonical(7), + ), + ), + ), + // Only REVISION_CONFLICT may carry a state-changing canonical. + successResponse( + profilePreferencesAccepted = true, + canonicalProfilePreferenceSections = listOf(workout), + profilePreferenceRejections = listOf( + ProfilePreferenceSectionRejectionDto( + localProfileId = "profile-a", + section = "CORE", + serverRevision = 7, + reason = "VALIDATION_FAILED", + canonicalSection = coreCanonical(7), + ), + ), + ), + // A conflict without its mandatory canonical is malformed and cannot mutate CORE. + successResponse( + profilePreferencesAccepted = true, + canonicalProfilePreferenceSections = listOf(workout), + profilePreferenceRejections = listOf( + ProfilePreferenceSectionRejectionDto( + localProfileId = "profile-a", + section = "CORE", + serverRevision = 7, + reason = "REVISION_CONFLICT", + canonicalSection = null, + ), + ), + ), + ) + + cases.forEach { response -> + val outcomes = buildProfilePreferencePushOutcomes(response, ledger) + assertEquals(listOf(workoutKey), outcomes.map { it.key }) + assertEquals(12L, outcomes.single().sentLocalGeneration) + } +} + +@Test +fun `profile preference diagnostics never expose raw identities sections or messages`() { + val sentinel = "SECRET_PROFILE_DIAGNOSTIC_SENTINEL" + val key = ProfilePreferenceSectionKey( + localProfileId = "$sentinel\u0000", + section = ProfilePreferenceSectionName.CORE, + ) + val lines = listOf( + profilePreferenceIssueLogLine( + ProfilePreferenceSyncIssue( + key = key, + localGeneration = 9, + reason = ProfilePreferenceSyncIssueReason.INVALID_PROFILE_ID.name, + ), + ), + profilePreferenceIssueLogLine( + ProfilePreferenceSyncIssue( + key = key, + localGeneration = 9, + reason = sentinel, + ), + ), + profilePreferenceMetadataDeferredLogLine(key), + profilePreferenceDuplicateResultLogLine(key), + profilePreferenceInvalidCanonicalLogLine( + ProfilePreferenceCanonicalDecodeResult.Invalid( + localProfileId = sentinel, + section = sentinel, + reason = sentinel, + ), + ), + profilePreferenceChunkFailureLogLine( + PortalApiException(sentinel, statusCode = 503), + ), + profilePreferenceLocalFailureLogLine( + ProfilePreferenceLocalFailureStage.RESPONSE_MAPPING, + ), + ) + + assertEquals( + listOf( + "PROFILE_PREFERENCE_NOT_SENT section=CORE reason=INVALID_PROFILE_ID", + "PROFILE_PREFERENCE_NOT_SENT section=CORE reason=INVALID_PROFILE_PREFERENCE_DIAGNOSTIC", + "PROFILE_PREFERENCE_NOT_SENT section=CORE reason=PROFILE_METADATA_NOT_SENT", + "PROFILE_PREFERENCE_DUPLICATE_RESULT section=CORE", + "PROFILE_PREFERENCE_INVALID_CANONICAL reason=INVALID_PROFILE_PREFERENCE_DIAGNOSTIC", + "PROFILE_PREFERENCE_CHUNK_FAILED status=503", + "PROFILE_PREFERENCE_LOCAL_FAILURE stage=RESPONSE_MAPPING", + ), + lines, + ) + lines.forEach { line -> + assertFalse(sentinel in line) + assertFalse('\u0000' in line) + } +} + +@Test +fun `local preference isolation sanitizes every stage and rethrows cancellation`() = runTest { + val sentinel = "SECRET_LOCAL_FAILURE_SENTINEL" + ProfilePreferenceLocalFailureStage.entries.forEach { stage -> + val emitted = mutableListOf() + val result = isolateProfilePreferenceFailure( + stage = stage, + onFailure = emitted::add, + ) { + throw IllegalStateException(sentinel) + } + + assertNull(result) + assertEquals( + listOf("PROFILE_PREFERENCE_LOCAL_FAILURE stage=${stage.name}"), + emitted, + ) + assertTrue(emitted.none { sentinel in it }) + } + + val emitted = mutableListOf() + assertFailsWith { + isolateProfilePreferenceFailure( + stage = ProfilePreferenceLocalFailureStage.SNAPSHOT, + onFailure = emitted::add, + ) { + throw CancellationException(sentinel) + } + } + assertTrue(emitted.isEmpty()) +} +``` + +Import `CancellationException`, `assertFailsWith`, and `assertFalse`. These tests pass attacker-controlled text through every diagnostic and failure-isolation boundary, including the later pull helper, without relying on a logging backend. Cancellation is control flow, not a recoverable preference-local failure, and must escape unchanged. + +Task 4's SQLDelight sync repository tests separately mutate the row after snapshot and prove that both acceptance and conflict outcomes advance the server revision without overwriting the newer payload or clearing dirty state. + +Also add this explicit policy test to `SqlDelightProfilePreferenceSyncRepositoryTest.kt`. It documents, rather than changes, the approved matching-generation server-wins rule. The first generation is assumed committed remotely while its response is lost; because the later edit is itself the generation sent by the stale-base retry, the returned conflict canonical wins that sent snapshot: + +```kotlin +@Test +fun `lost acknowledgement then later edit retains approved matching generation server wins policy`() = + runTest { + createProfile("lost-ack-edit") + foundationRepository.insertDefaults("lost-ack-edit") + foundationRepository.updateCore( + "lost-ack-edit", + CoreProfilePreferences(bodyWeightKg = 80f), + now = 20, + ) + val committedButUnacknowledged = repository.snapshotDirtySections().valid.single { + it.key.localProfileId == "lost-ack-edit" && + it.key.section == ProfilePreferenceSectionName.CORE + } + + // Simulate the server committing generation 1 at revision 1 and the HTTP response + // being lost: do not apply an outcome locally before the user edits again. + foundationRepository.updateCore( + "lost-ack-edit", + CoreProfilePreferences(bodyWeightKg = 90f), + now = 30, + ) + val retry = repository.snapshotDirtySections().valid.single { + it.key == committedButUnacknowledged.key + } + assertTrue(retry.localGeneration > committedButUnacknowledged.localGeneration) + assertEquals(0L, retry.baseRevision) + + repository.applyPushOutcomes( + listOf( + ProfilePreferencePushOutcome( + key = retry.key, + sentLocalGeneration = retry.localGeneration, + serverRevision = 1, + canonical = coreCanonical(revision = 1, bodyWeightKg = 80.0), + rejectionReason = "REVISION_CONFLICT", + ), + ), + ) + + val current = foundationRepository.get("lost-ack-edit").core + assertEquals(80f, current.value.bodyWeightKg) + assertEquals(1L, current.metadata.serverRevision) + assertFalse(current.metadata.dirty) + } +``` + +Do not silently convert conflicts to local-dirty rebases in Task 7. Preserving the later `90f` edit would require a product/spec change—either retaining and replaying the failed generation-1 wire mutation/ledger before generation 2, or redefining the matching-generation conflict policy. + +- [ ] **Step 4: Run the tests and verify missing orchestration failures** + +Run: + +```powershell +.\gradlew.bat :shared:testAndroidHostTest --tests "*SyncManagerProfilePreferencesTest*" --tests "*SqlDelightProfilePreferenceSyncRepositoryTest*" -Pskip.supabase.check=true +``` + +Expected: FAIL because `SyncManager` has no readiness function or preference push loop. + +- [ ] **Step 5: Inject migration readiness without blocking ordinary sync** + +Add to the `SyncManager` constructor: + +```kotlin +private val profilePreferenceSyncRepository: ProfilePreferenceSyncRepository, +private val isProfilePreferenceMigrationReady: () -> Boolean, +``` + +Add these explicit imports to `SyncModule.kt`; the file currently imports selected sync types rather than the package wildcard: + +```kotlin +import com.devil.phoenixproject.data.sync.ProfilePreferenceSyncCodec +import com.devil.phoenixproject.data.sync.ProfilePreferenceSyncRepository +import com.devil.phoenixproject.data.sync.SqlDelightProfilePreferenceSyncRepository +``` + +Bind the focused codec/repository and capture the migration manager before constructing `SyncManager` in `SyncModule`: + +```kotlin +single { ProfilePreferenceSyncCodec() } +single { + SqlDelightProfilePreferenceSyncRepository(database = get(), codec = get()) +} + +single { + val migrationManager = get() + SyncManager( + apiClient = get(), + tokenStorage = get(), + syncRepository = get(), + gamificationRepository = get(), + repMetricRepository = get(), + userProfileRepository = get(), + profilePreferenceSyncRepository = get(), + externalActivityRepository = get(), + velocityOneRepMaxRepository = get(), + isProfilePreferenceMigrationReady = { + migrationManager.requiredMigrationState.value is + com.devil.phoenixproject.data.migration.RequiredMigrationState.Ready + }, + ) +} +``` + +Update every existing `SyncManager` test constructor to pass a `FakeProfilePreferenceSyncRepository` and `{ true }`; the focused readiness test passes a mutable lambda whose value changes from false to true. Keep `Function0::class` in `KoinModuleVerifyTest.extraTypes`, then include `KoinModuleVerifyTest` in Step 9 so the new internal codec/repository bindings and captured `MigrationManager` dependency are verified in the assembled app graph. + +- [ ] **Step 6: Rate-limit every logical push API call** + +Replace the single acquisition at the top of `pushLocalChanges` with this helper and route every ordinary and preference call through it: + +```kotlin +private suspend fun pushPayloadWithRateLimit( + payload: PortalSyncPayload, +): Result { + if (!rateLimiter.tryAcquire("push", SyncConfig.PUSH_RATE_LIMIT_PER_MIN)) { + return Result.failure( + PortalApiException( + "Client rate limit exceeded for push (${SyncConfig.PUSH_RATE_LIMIT_PER_MIN}/min). " + + "Try again shortly.", + statusCode = 429, + ), + ) + } + return apiClient.pushPortalPayload(payload) +} +``` + +One limiter token is consumed for every ordinary batch or preference chunk passed to `pushPortalPayload`. `PortalApiClient` may internally repeat the same physical HTTP push once after a 401 and token refresh; that transparent auth-recovery attempt is the explicit exception and does not consume a second `SyncManager` token. `FakePortalApiClient.pushCallCount` and its result queues therefore count logical API calls, while Task 6's captured-engine test alone counts physical HTTP attempts. The refresh endpoint is authentication traffic, not a sync push. The shared 429 text stays generic because this helper also owns ordinary session batches. The existing session-batch failure contract remains unchanged. A rate limit reached during preference-only chunks stops preference sending, leaves unsent sections dirty, and retains the successful ordinary push. + +- [ ] **Step 7: Implement the preference-only push loop** + +Import `kotlin.coroutines.cancellation.CancellationException`, then add these module-internal pure diagnostic and failure-isolation helpers in `SyncManager.kt`; production logging in Tasks 7 and 8 must call them rather than interpolating raw objects or throwable messages: + +```kotlin +private val PROFILE_PREFERENCE_REASON_NAMES = + ProfilePreferenceSyncIssueReason.entries.mapTo(mutableSetOf()) { it.name } + +private fun safeProfilePreferenceReason(reason: String): String = + reason.takeIf { it in PROFILE_PREFERENCE_REASON_NAMES } + ?: "INVALID_PROFILE_PREFERENCE_DIAGNOSTIC" + +internal enum class ProfilePreferenceLocalFailureStage { + SNAPSHOT, + MUTATION_MAPPING, + CHUNK_PLANNING, + RESPONSE_MAPPING, + OUTCOME_APPLY, + PULL_RESPONSE_MAPPING, + PULL_APPLY, +} + +internal fun profilePreferenceIssueLogLine(issue: ProfilePreferenceSyncIssue): String = + "PROFILE_PREFERENCE_NOT_SENT section=${issue.key.section.name} " + + "reason=${safeProfilePreferenceReason(issue.reason)}" + +internal fun profilePreferenceMetadataDeferredLogLine( + key: ProfilePreferenceSectionKey, +): String = "PROFILE_PREFERENCE_NOT_SENT section=${key.section.name} " + + "reason=PROFILE_METADATA_NOT_SENT" + +internal fun profilePreferenceDuplicateResultLogLine( + key: ProfilePreferenceSectionKey, +): String = "PROFILE_PREFERENCE_DUPLICATE_RESULT section=${key.section.name}" + +internal fun profilePreferenceInvalidCanonicalLogLine( + invalid: ProfilePreferenceCanonicalDecodeResult.Invalid, +): String = "PROFILE_PREFERENCE_INVALID_CANONICAL " + + "reason=${safeProfilePreferenceReason(invalid.reason)}" + +internal fun profilePreferenceChunkFailureLogLine(error: Throwable?): String { + val status = (error as? PortalApiException)?.statusCode?.toString() ?: "UNKNOWN" + return "PROFILE_PREFERENCE_CHUNK_FAILED status=$status" +} + +internal fun profilePreferenceLocalFailureLogLine( + stage: ProfilePreferenceLocalFailureStage, +): String = "PROFILE_PREFERENCE_LOCAL_FAILURE stage=${stage.name}" + +internal suspend fun isolateProfilePreferenceFailure( + stage: ProfilePreferenceLocalFailureStage, + onFailure: (String) -> Unit, + block: suspend () -> T, +): T? = try { + block() +} catch (cancelled: CancellationException) { + throw cancelled +} catch (_: Exception) { + onFailure(profilePreferenceLocalFailureLogLine(stage)) + null +} +``` + +These helpers intentionally omit `localProfileId`, the raw remote `section`, `ProfilePreferenceSectionKey.toString()`, local generation, exception class/message, and all values. The isolation boundary never passes the exception to its logger and never catches cancellation. After all ordinary batches succeed and after the exact `profileDtos` list has been sent as `allProfiles`, call the preference loop with that same metadata ID set. A profile created while ordinary requests are in flight is therefore deferred until a later sync sends its parent: + +```kotlin +private suspend fun pushDirtyProfilePreferences( + deviceId: String, + platform: String, + lastSync: Long, + sentMetadataProfileIds: Set, +) { + if (!isProfilePreferenceMigrationReady()) return + val safeFailureLogger: (String) -> Unit = { line -> + Logger.w("SyncManager") { line } + } + val snapshot = isolateProfilePreferenceFailure( + ProfilePreferenceLocalFailureStage.SNAPSHOT, + safeFailureLogger, + ) { + profilePreferenceSyncRepository.snapshotDirtySections() + } ?: return + snapshot.unsyncable.forEach { issue -> + Logger.w("SyncManager") { profilePreferenceIssueLogLine(issue) } + } + val (eligible, deferred) = snapshot.valid.partition { + it.key.localProfileId in sentMetadataProfileIds + } + deferred.forEach { section -> + Logger.i("SyncManager") { profilePreferenceMetadataDeferredLogLine(section.key) } + } + val prepared = isolateProfilePreferenceFailure( + ProfilePreferenceLocalFailureStage.MUTATION_MAPPING, + safeFailureLogger, + ) { + eligible.map(PortalSyncAdapter::toPortalProfilePreferenceMutation) + } ?: return + if (prepared.isEmpty()) return + + val base = PortalSyncPayload( + deviceId = deviceId, + platform = platform, + lastSync = lastSync, + ) + val plan = isolateProfilePreferenceFailure( + ProfilePreferenceLocalFailureStage.CHUNK_PLANNING, + safeFailureLogger, + ) { + planProfilePreferencePushChunks(base, prepared) + } ?: return + plan.unsyncable.forEach { issue -> + Logger.w("SyncManager") { profilePreferenceIssueLogLine(issue) } + } + + for (chunk in plan.chunks) { + val result = pushPayloadWithRateLimit(chunk.payload) + if (result.isFailure) { + Logger.w("SyncManager") { + profilePreferenceChunkFailureLogLine(result.exceptionOrNull()) + } + return + } + val response = result.getOrThrow() + if (response.profilePreferencesAccepted != true) { + Logger.i("SyncManager") { "Backend did not acknowledge profile preference support" } + return + } + val outcomes = isolateProfilePreferenceFailure( + ProfilePreferenceLocalFailureStage.RESPONSE_MAPPING, + safeFailureLogger, + ) { + buildProfilePreferencePushOutcomes(response, chunk.ledger) + } ?: return + if (outcomes.isNotEmpty()) { + isolateProfilePreferenceFailure( + ProfilePreferenceLocalFailureStage.OUTCOME_APPLY, + safeFailureLogger, + ) { + profilePreferenceSyncRepository.applyPushOutcomes(outcomes) + } ?: return + } + } +} +``` + +Every non-cancellation failure in snapshot, mutation mapping, chunk planning, response mapping, or outcome application stops only the preference loop. The already-successful ordinary response remains `lastResponse`, so existing external-activity, personal-record, and session acknowledgement handling continues. A response-mapping or apply failure is treated as a lost preference acknowledgement: no later chunk is attempted, the current local generations remain dirty, and a later retry converges through the ordinary base-revision conflict contract. + +Implement module-internal `buildProfilePreferencePushOutcomes` so the focused tests can call the pure response mapper directly. It: + +- accepts only canonical/rejection keys present in the chunk ledger; +- increments the known-ledger-key response count before validating a canonical or rejection, so a malformed duplicate cannot hide behind validation and allow its valid twin to apply; +- requires exactly one total response entry per ledger key across both response arrays; canonical-plus-rejection, two canonicals, or two rejections for one key are invariant violations and produce no outcome for only that key; +- maps canonical DTOs through `PortalPullAdapter.toCanonicalProfilePreferenceSection`; +- allowlists the seven Edge reasons, but creates a state-changing rejection outcome only for `REVISION_CONFLICT` with a mandatory valid canonical whose key and revision match the outer rejection; +- treats validation, unsupported, unknown-profile, size, and duplicate rejections as non-acknowledgements that leave the section dirty even if a drifting backend unexpectedly attaches a canonical; +- attaches `sentLocalGeneration` from the ledger; +- leaves a sent key dirty when its entry is missing, unknown, malformed, or non-state-changing; +- never maps local safety/consent fields. + +Use one candidate list and group before applying anything: + +```kotlin +private data class PreferenceOutcomeCandidate( + val key: ProfilePreferenceSectionKey, + val serverRevision: Long, + val canonical: CanonicalProfilePreferenceSection, + val rejectionReason: String?, +) + +private val PROFILE_PREFERENCE_REJECTION_REASONS = setOf( + "REVISION_CONFLICT", + "VALIDATION_FAILED", + "UNSUPPORTED_SECTION", + "UNSUPPORTED_DOCUMENT_VERSION", + "UNKNOWN_PROFILE", + "SECTION_TOO_LARGE", + "DUPLICATE_SECTION", +) + +private fun responseKey(localProfileId: String, section: String): ProfilePreferenceSectionKey? { + val parsed = ProfilePreferenceSectionName.entries.firstOrNull { it.name == section } + ?: return null + return ProfilePreferenceSectionKey(localProfileId, parsed) +} + +internal fun buildProfilePreferencePushOutcomes( + response: PortalSyncPushResponse, + ledger: Map, +): List { + val candidates = mutableListOf() + val responseCounts = mutableMapOf() + response.canonicalProfilePreferenceSections.forEach { dto -> + val key = responseKey(dto.localProfileId, dto.section) ?: return@forEach + if (key !in ledger) return@forEach + responseCounts[key] = responseCounts.getOrElse(key) { 0 } + 1 + val decoded = PortalPullAdapter.toCanonicalProfilePreferenceSection(dto) + if (decoded is ProfilePreferenceCanonicalDecodeResult.Valid && decoded.section.key == key) { + candidates += PreferenceOutcomeCandidate( + key = key, + serverRevision = decoded.section.serverRevision, + canonical = decoded.section, + rejectionReason = null, + ) + } + } + response.profilePreferenceRejections.forEach { rejection -> + val key = responseKey(rejection.localProfileId, rejection.section) ?: return@forEach + if (key !in ledger) return@forEach + // Count before reason/canonical validation. A malformed second known-key entry must + // invalidate only that key rather than let a valid first entry apply. + responseCounts[key] = responseCounts.getOrElse(key) { 0 } + 1 + if (rejection.reason !in PROFILE_PREFERENCE_REJECTION_REASONS) return@forEach + if (rejection.reason != "REVISION_CONFLICT") return@forEach + val canonicalDto = rejection.canonicalSection ?: return@forEach + val canonical = PortalPullAdapter.toCanonicalProfilePreferenceSection(canonicalDto) + val decodedCanonical = (canonical as? ProfilePreferenceCanonicalDecodeResult.Valid) + ?.section + ?: return@forEach + if (decodedCanonical.key != key || + decodedCanonical.serverRevision != rejection.serverRevision + ) return@forEach + candidates += PreferenceOutcomeCandidate( + key = key, + serverRevision = rejection.serverRevision, + canonical = decodedCanonical, + rejectionReason = rejection.reason, + ) + } + return candidates.groupBy(PreferenceOutcomeCandidate::key).mapNotNull { (key, entries) -> + if (entries.size != 1 || responseCounts[key] != 1) { + Logger.w("SyncManager") { profilePreferenceDuplicateResultLogLine(key) } + return@mapNotNull null + } + val candidate = entries.single() + ProfilePreferencePushOutcome( + key = key, + sentLocalGeneration = ledger.getValue(key), + serverRevision = candidate.serverRevision, + canonical = candidate.canonical, + rejectionReason = candidate.rejectionReason, + ) + } +} +``` + +Call it after the ordinary batch loop and before external-activity/PR acknowledgement stamping: + +```kotlin +val sentMetadataProfileIds = profileDtos.mapTo(linkedSetOf()) { it.id } +pushDirtyProfilePreferences( + deviceId = deviceId, + platform = platform, + lastSync = lastSync, + sentMetadataProfileIds = sentMetadataProfileIds, +) +``` + +`sentMetadataProfileIds` must be derived from the exact immutable `profileDtos` list already placed in the successful ordinary metadata payload, not from a fresh `allProfiles.value` read. Do not pass the active profile id or name. Preserve and return the ordinary `lastResponse` so existing entity rejection handling remains intact. + +- [ ] **Step 8: Extend batching tests for metadata ordering and logical rate accounting** + +Add to `PortalPushLimitsTest`: + +```kotlin +@Test +fun preferenceChunksFollowTheFinalMetadataBatch() = runTest { + authenticate() + fakeUserProfileRepo.setActiveProfileForTest("profile-a") + fakeSyncRepo.workoutSessionsToReturn = buildSessions(73) + fakeProfilePreferenceSyncRepo.dirtySnapshot = ProfilePreferenceDirtySnapshot( + valid = listOf(coreSectionForSync()), + unsyncable = emptyList(), + ) + fakeApi.pushResult = Result.success( + PortalSyncPushResponse( + syncTime = "2026-07-11T12:00:00Z", + profilePreferencesAccepted = true, + canonicalProfilePreferenceSections = listOf(coreCanonicalForSync()), + ), + ) + + createManager().sync() + + val preferenceIndex = fakeApi.pushPayloads.indexOfFirst { it.profilePreferenceSections != null } + val metadataIndex = fakeApi.pushPayloads.indexOfLast { it.allProfiles != null } + assertTrue(metadataIndex >= 0) + assertTrue(preferenceIndex > metadataIndex) + assertTrue("profile-a" in fakeApi.pushPayloads[metadataIndex].allProfiles.orEmpty().map { it.id }) +} + +@Test +fun everyLogicalPushCallConsumesTheSharedRateLimit() = runTest { + authenticate() + repeat(20) { index -> + fakeUserProfileRepo.setActiveProfileForTest("profile-$index") + } + fakeUserProfileRepo.setActiveProfileForTest("profile-a") + val ordinary = buildSessions(1).single() + fakeSyncRepo.workoutSessionsToReturn = listOf(ordinary) + fakeProfilePreferenceSyncRepo.dirtySnapshot = ProfilePreferenceDirtySnapshot( + valid = List(20) { index -> + ProfilePreferenceSectionSyncDto( + key = ProfilePreferenceSectionKey( + "profile-$index", + ProfilePreferenceSectionName.RACK, + ), + documentVersion = 1, + baseRevision = 0, + clientModifiedAtEpochMs = 1_783_771_200_000L, + localGeneration = 1, + payload = buildJsonObject { put("padding", "x".repeat(174_700)) }, + ) + }, + unsyncable = emptyList(), + ) + fakeApi.pushResult = Result.success( + PortalSyncPushResponse( + syncTime = "2026-07-11T12:00:00Z", + profilePreferencesAccepted = true, + ), + ) + + val result = createManager( + rateLimiter = ClientRateLimiter(nowMs = { 0L }), + ).sync() + + assertTrue(result.isSuccess) + assertEquals(SyncConfig.PUSH_RATE_LIMIT_PER_MIN, fakeApi.pushPayloads.size) + assertEquals( + SyncConfig.PUSH_RATE_LIMIT_PER_MIN - 1, + fakeApi.pushPayloads.count { it.profilePreferenceSections != null }, + ) + assertTrue(fakeProfilePreferenceSyncRepo.appliedPushOutcomes.isEmpty()) + assertEquals(listOf(ordinary.id), fakeSyncRepo.updateSessionTimestampCalls) +} +``` + +Add these local fixtures to `PortalPushLimitsTest`: + +```kotlin +private fun coreSectionForSync() = ProfilePreferenceSectionSyncDto( + key = ProfilePreferenceSectionKey("profile-a", ProfilePreferenceSectionName.CORE), + documentVersion = 1, + baseRevision = 0, + clientModifiedAtEpochMs = 1_783_771_200_000L, + localGeneration = 1, + payload = buildJsonObject { + put("bodyWeightKg", 80.0) + put("weightUnit", "KG") + put("weightIncrement", 0.5) + }, +) + +private fun coreCanonicalForSync() = PortalProfilePreferenceSectionCanonicalDto( + localProfileId = "profile-a", + section = "CORE", + documentVersion = 1, + serverRevision = 1, + serverUpdatedAt = "2026-07-11T12:00:00Z", + payload = coreSectionForSync().payload, +) +``` + +- [ ] **Step 9: Run push orchestration and regression tests** + +Run: + +```powershell +.\gradlew.bat :shared:testAndroidHostTest --tests "*SyncManagerProfilePreferencesTest*" --tests "*PortalPushLimitsTest*" --tests "*SyncManagerTest*" --tests "*PortalTokenRefreshTest*" --tests "*PortalPullPaginationTest*" --tests "*SqlDelightProfilePreferenceSyncRepositoryTest*" --tests "*KoinModuleVerifyTest*" -Pskip.supabase.check=true +``` + +Expected: PASS. + +- [ ] **Step 10: Commit metadata-first preference push** + +```powershell +git add shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/SyncManager.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/di/SyncModule.kt shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeProfilePreferenceSyncRepository.kt shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/SyncManagerProfilePreferencesTest.kt shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/PortalPushLimitsTest.kt shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/SyncManagerTest.kt shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/PortalTokenRefreshTest.kt shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/PortalPullPaginationTest.kt shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/sync/SqlDelightProfilePreferenceSyncRepositoryTest.kt +git commit -m "feat(sync): push revisioned profile preference sections" +``` + +--- + +### Task 8: Merge Preference Pulls Before Existing Entities + +**Files:** +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/SyncManager.kt` +- Modify: `shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/SyncManagerProfilePreferencesTest.kt` +- Modify: `shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/PortalPullPaginationTest.kt` +- Modify: `shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeSyncRepository.kt` +- Consume: `shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeProfilePreferenceSyncRepository.kt` — Task 7's attempted-call/raw-input and successful-known-application captures. + +**Interfaces:** +- Consumes: canonical pull DTOs, Task 5's strict pull adapter, Task 4's guarded transactional preference repository, and Task 7's dynamic readiness lambda plus `PULL_RESPONSE_MAPPING`/`PULL_APPLY` cancellation-safe isolation stages. +- Produces: first-page-only, duplicate-key-safe, unknown-profile-safe, preference-first pull convergence without changing `localProfiles` lifecycle behavior or overwriting a concurrent dirty local edit. + +- [ ] **Step 1: Write failing pure pull-planning and sanitized-diagnostic tests** + +Add to `SyncManagerProfilePreferencesTest`: + +```kotlin +@Test +fun `pull planner pre-counts duplicate keys before validation and keeps valid siblings`() { + val sentinel = "SECRET_PULL_PROFILE_SENTINEL" + val plan = planProfilePreferencePullSections( + listOf( + coreCanonical(revision = 3), + coreCanonical(revision = 4).copy(documentVersion = 2), + workoutCanonical(revision = 5), + coreCanonical(revision = 6).copy(localProfileId = "$sentinel\u0000"), + ), + ) + + assertEquals( + listOf(ProfilePreferenceSectionName.WORKOUT), + plan.valid.map { it.key.section }, + ) + assertEquals(1, plan.duplicateKeyCount) + assertEquals(2, plan.invalidCanonicalCount) +} + +@Test +fun `pull diagnostics contain only fixed categories and counts`() { + val sentinel = "SECRET_PULL_DIAGNOSTIC_SENTINEL" + val lines = ProfilePreferencePullDiagnosticCategory.entries.map { category -> + profilePreferencePullCountLogLine(category, 7) + } + profilePreferenceInvalidCanonicalLogLine( + ProfilePreferenceCanonicalDecodeResult.Invalid( + localProfileId = sentinel, + section = sentinel, + reason = sentinel, + ), + ) + + assertEquals( + listOf( + "PROFILE_PREFERENCE_PULL category=INVALID_CANONICAL count=7", + "PROFILE_PREFERENCE_PULL category=DUPLICATE_KEY count=7", + "PROFILE_PREFERENCE_PULL category=LATER_PAGE_IGNORED count=7", + "PROFILE_PREFERENCE_PULL category=UNKNOWN_PROFILE count=7", + "PROFILE_PREFERENCE_PULL category=REPOSITORY_INVALID count=7", + "PROFILE_PREFERENCE_INVALID_CANONICAL reason=INVALID_PROFILE_PREFERENCE_DIAGNOSTIC", + ), + lines, + ) + assertTrue(lines.none { sentinel in it }) +} +``` + +- [ ] **Step 2: Write failing ordering, unknown-profile, and lifecycle tests** + +Add to `SyncManagerProfilePreferencesTest`: + +```kotlin +@Test +fun `pull applies known preference section before existing entities`() = runTest { + val mergeEvents = mutableListOf() + harness.preferenceSyncRepository.onApplyPulledSections = { mergeEvents += "preferences" } + harness.syncRepository.onMergeAllPullData = { mergeEvents += "entities" } + harness.api.pullResult = Result.success( + PortalSyncPullResponse( + syncTime = 1_783_771_200_000L, + profilePreferenceSections = listOf(coreCanonical(revision = 3)), + routines = listOf(PullRoutineDto(id = "routine-1", name = "Routine")), + ), + ) + + assertTrue(harness.manager(migrationReady = { true }).sync().isSuccess) + + assertEquals(1, harness.preferenceSyncRepository.pullApplyCallCount) + assertEquals(3, harness.preferenceSyncRepository.appliedPulledSections.single().single().serverRevision) + assertEquals(1, harness.syncRepository.atomicMergeCallCount) + assertEquals(listOf("preferences", "entities"), mergeEvents) +} + +@Test +fun `pull reports unknown preference without creating a profile or preference row`() = runTest { + harness.api.pullResult = Result.success( + PortalSyncPullResponse( + syncTime = 1_783_771_200_000L, + profilePreferenceSections = listOf( + coreCanonical(revision = 3).copy(localProfileId = "remote-only-profile"), + ), + ), + ) + + assertTrue(harness.manager(migrationReady = { true }).sync().isSuccess) + + assertEquals(1, harness.preferenceSyncRepository.pullApplyCallCount) + assertEquals( + "remote-only-profile", + harness.preferenceSyncRepository.pulledSectionCalls.single().single().key.localProfileId, + ) + assertTrue(harness.preferenceSyncRepository.appliedPulledSections.single().isEmpty()) + assertTrue(harness.profileRepository.allProfiles.value.none { it.id == "remote-only-profile" }) +} + +@Test +fun `localProfiles metadata still does not create mobile profiles`() = runTest { + harness.api.pullResult = Result.success( + PortalSyncPullResponse( + syncTime = 1_783_771_200_000L, + localProfiles = listOf(LocalProfileDto("remote-only-profile", "Remote", 2)), + ), + ) + + assertTrue(harness.manager(migrationReady = { true }).sync().isSuccess) + + assertEquals(0, harness.preferenceSyncRepository.pullApplyCallCount) + assertTrue(harness.profileRepository.allProfiles.value.none { it.id == "remote-only-profile" }) +} +``` + +The focused fake records every attempted repository call in `pulledSectionCalls` before its injected `applyPullFailure`, then records only successful known-profile applications in `appliedPulledSections` after that failure seam. Task 4 proves the production repository performs the equivalent schema-43 row lookup without creating either a profile or a preference row. `SyncManager` must not prefilter through a potentially stale `allProfiles.value`; the repository owns the transactional existence check. + +Add this test-only hook to `FakeSyncRepository` and invoke it immediately after the simulated-failure guard and before `atomicMergeCallCount++` in `mergeAllPullData`: + +```kotlin +var onMergeAllPullData: (() -> Unit)? = null + +// Inside mergeAllPullData, before captures are mutated: +onMergeAllPullData?.invoke() +``` + +- [ ] **Step 3: Write failing dynamic-readiness, first-page-only, and pagination tests** + +Add to `SyncManagerProfilePreferencesTest`: + +```kotlin +@Test +fun `pull readiness is dynamic while not Ready still merges ordinary pages`() = runTest { + var migrationState: RequiredMigrationState = RequiredMigrationState.NotStarted + val manager = harness.manager( + migrationReady = { migrationState is RequiredMigrationState.Ready }, + ) + harness.api.pullResultsQueue = mutableListOf( + Result.success( + PortalSyncPullResponse( + syncTime = 1_783_771_200_000L, + hasMore = true, + nextCursor = "page-2", + profilePreferenceSections = listOf(coreCanonical(revision = 2)), + ), + ), + Result.success( + PortalSyncPullResponse( + syncTime = 1_783_771_201_000L, + routines = listOf(PullRoutineDto(id = "routine-1", name = "Routine")), + ), + ), + ) + + assertTrue(manager.sync().isSuccess) + assertEquals(2, harness.api.pullCallCount) + assertEquals(0, harness.preferenceSyncRepository.pullApplyCallCount) + assertEquals(2, harness.syncRepository.atomicMergeCallCount) + + migrationState = RequiredMigrationState.Ready + harness.api.pullResultsQueue = mutableListOf( + Result.success( + PortalSyncPullResponse( + syncTime = 1_783_771_202_000L, + profilePreferenceSections = listOf(coreCanonical(revision = 3)), + routines = listOf(PullRoutineDto(id = "routine-2", name = "Routine 2")), + ), + ), + ) + + assertTrue(manager.sync().isSuccess) + assertEquals(1, harness.preferenceSyncRepository.pullApplyCallCount) + assertEquals(3, harness.preferenceSyncRepository.appliedPulledSections.single().single().serverRevision) + assertEquals(3, harness.syncRepository.atomicMergeCallCount) +} +``` + +Add to `PortalPullPaginationTest`: + +```kotlin +@Test +fun preferenceOnlyPageCountsAsNonEmptyAndContinuesPagination() = runTest { + authenticate() + fakeApi.pullResultsQueue = mutableListOf( + Result.success( + PortalSyncPullResponse( + syncTime = 1_783_771_200_000L, + hasMore = true, + nextCursor = "page-2", + profilePreferenceSections = listOf(coreCanonicalForPull(revision = 2)), + ), + ), + Result.success( + PortalSyncPullResponse( + syncTime = 1_783_771_201_000L, + hasMore = false, + ), + ), + ) + + val result = createManager().sync() + + assertTrue(result.isSuccess) + assertEquals(2, fakeApi.pullCallCount) + assertEquals(listOf(null, "page-2"), fakeApi.pullCallCursors) +} + +@Test +fun laterPagePreferenceIsIgnoredButStillKeepsPaginationMoving() = runTest { + authenticate() + fakeApi.pullResultsQueue = mutableListOf( + Result.success( + PortalSyncPullResponse( + syncTime = 1_783_771_200_000L, + hasMore = true, + nextCursor = "page-2", + routines = listOf(PullRoutineDto(id = "routine-1", name = "Routine 1")), + ), + ), + Result.success( + PortalSyncPullResponse( + syncTime = 1_783_771_201_000L, + hasMore = true, + nextCursor = "page-3", + profilePreferenceSections = listOf(coreCanonicalForPull(revision = 9)), + ), + ), + Result.success( + PortalSyncPullResponse( + syncTime = 1_783_771_202_000L, + routines = listOf(PullRoutineDto(id = "routine-3", name = "Routine 3")), + ), + ), + ) + + assertTrue(createManager().sync().isSuccess) + + assertEquals(3, fakeApi.pullCallCount) + assertEquals(listOf(null, "page-2", "page-3"), fakeApi.pullCallCursors) + assertEquals(0, fakeProfilePreferenceSyncRepo.pullApplyCallCount) + assertEquals(3, fakeSyncRepo.atomicMergeCallCount) +} +``` + +Add this local pull fixture: + +```kotlin +private fun coreCanonicalForPull(revision: Long) = + PortalProfilePreferenceSectionCanonicalDto( + localProfileId = "profile-a", + section = "CORE", + documentVersion = 1, + serverRevision = revision, + serverUpdatedAt = "2026-07-11T12:00:00Z", + payload = buildJsonObject { + put("bodyWeightKg", 80.0) + put("weightUnit", "KG") + put("weightIncrement", 0.5) + }, + ) +``` + +- [ ] **Step 4: Write failing failure-isolation and partial-commit tests** + +Add to `SyncManagerProfilePreferencesTest`: + +```kotlin +@Test +fun `preference apply failure is local and ordinary entities still merge`() = runTest { + harness.preferenceSyncRepository.applyPullFailure = + IllegalStateException("SECRET_PULL_APPLY_FAILURE") + harness.api.pullResult = Result.success( + PortalSyncPullResponse( + syncTime = 1_783_771_200_000L, + profilePreferenceSections = listOf(coreCanonical(revision = 3)), + routines = listOf(PullRoutineDto(id = "routine-1", name = "Routine")), + ), + ) + + assertTrue(harness.manager(migrationReady = { true }).sync().isSuccess) + + assertEquals(1, harness.preferenceSyncRepository.pullApplyCallCount) + assertEquals(1, harness.preferenceSyncRepository.pulledSectionCalls.size) + assertTrue(harness.preferenceSyncRepository.appliedPulledSections.isEmpty()) + assertEquals(1, harness.syncRepository.atomicMergeCallCount) +} + +@Test +fun `pull preference cancellation escapes before ordinary merge`() = runTest { + harness.preferenceSyncRepository.applyPullFailure = + CancellationException("SECRET_PULL_CANCELLATION") + harness.api.pullResult = Result.success( + PortalSyncPullResponse( + syncTime = 1_783_771_200_000L, + profilePreferenceSections = listOf(coreCanonical(revision = 3)), + routines = listOf(PullRoutineDto(id = "routine-1", name = "Routine")), + ), + ) + + assertFailsWith { + harness.manager(migrationReady = { true }).sync() + } + assertEquals(1, harness.preferenceSyncRepository.pullApplyCallCount) + assertEquals(0, harness.syncRepository.atomicMergeCallCount) +} + +@Test +fun `ordinary merge failure keeps earlier preference commit and checkpoint for retry`() = runTest { + val initialLastSync = 5_000L + harness.tokenStorage.setLastSyncTimestamp(initialLastSync) + harness.syncRepository.atomicMergeShouldFail = true + harness.api.pullResult = Result.success( + PortalSyncPullResponse( + syncTime = 1_783_771_200_000L, + profilePreferenceSections = listOf(coreCanonical(revision = 3)), + routines = listOf(PullRoutineDto(id = "routine-1", name = "Routine")), + ), + ) + val manager = harness.manager(migrationReady = { true }) + + assertTrue(manager.retryPull().isFailure) + assertIs(manager.syncState.value) + assertEquals(initialLastSync, harness.tokenStorage.getLastSyncTimestamp()) + assertEquals(1, harness.preferenceSyncRepository.appliedPulledSections.size) + + harness.syncRepository.atomicMergeShouldFail = false + assertTrue(manager.retryPull().isSuccess) + assertEquals(2, harness.preferenceSyncRepository.pullApplyCallCount) + assertEquals(1_783_771_200_000L, harness.tokenStorage.getLastSyncTimestamp()) + assertEquals(1, harness.syncRepository.atomicMergeCallCount) +} +``` + +Import `kotlin.coroutines.cancellation.CancellationException`, `assertFailsWith`, and `assertIs`. Task 7's enum-wide `isolateProfilePreferenceFailure` test supplies the pure throwing-block coverage for `PULL_RESPONSE_MAPPING`; the tests above prove that production orchestration uses `PULL_APPLY`, records an attempted raw call before failure, and rethrows cancellation unchanged. + +- [ ] **Step 5: Run the tests and verify pull handling failures** + +Run: + +```powershell +.\gradlew.bat :shared:testAndroidHostTest --tests "*SyncManagerProfilePreferencesTest*" --tests "*PortalPullPaginationTest*" -Pskip.supabase.check=true +``` + +Expected: FAIL because pull planning, first-page context, pagination counting, isolated application, and preference-first merge wiring do not exist. + +- [ ] **Step 6: Implement pure duplicate-safe pull planning and fixed diagnostics** + +Add to `SyncManager.kt` with an explicit `ProfilePreferenceSectionName` import: + +```kotlin +internal enum class ProfilePreferencePullDiagnosticCategory { + INVALID_CANONICAL, + DUPLICATE_KEY, + LATER_PAGE_IGNORED, + UNKNOWN_PROFILE, + REPOSITORY_INVALID, +} + +internal data class ProfilePreferencePullPlan( + val valid: List, + val invalidCanonicalCount: Int, + val duplicateKeyCount: Int, +) + +private fun profilePreferencePullEnvelopeKey( + dto: PortalProfilePreferenceSectionCanonicalDto, +): ProfilePreferenceSectionKey? { + val section = ProfilePreferenceSectionName.entries.firstOrNull { it.name == dto.section } + ?: return null + return ProfilePreferenceSectionKey(dto.localProfileId, section) +} + +internal fun planProfilePreferencePullSections( + dtos: List, +): ProfilePreferencePullPlan { + // Count supported outer keys before canonical validation. A malformed duplicate must + // invalidate its otherwise-valid twin rather than hide behind decoder rejection. + val keyCounts = dtos.mapNotNull(::profilePreferencePullEnvelopeKey) + .groupingBy { it } + .eachCount() + val duplicateKeys = keyCounts.filterValues { it > 1 }.keys + val decoded = dtos.map(PortalPullAdapter::toCanonicalProfilePreferenceSection) + return ProfilePreferencePullPlan( + valid = decoded + .filterIsInstance() + .map { it.section } + .filterNot { it.key in duplicateKeys }, + invalidCanonicalCount = decoded.count { + it is ProfilePreferenceCanonicalDecodeResult.Invalid + }, + duplicateKeyCount = duplicateKeys.size, + ) +} + +internal fun profilePreferencePullCountLogLine( + category: ProfilePreferencePullDiagnosticCategory, + count: Int, +): String = "PROFILE_PREFERENCE_PULL category=${category.name} count=$count" +``` + +The duplicate set is internal only. Never interpolate a key, raw profile id, raw remote section, decoder result, exception, or payload. Unsupported outer section strings cannot form a trusted key and count only as invalid canonicals. Every duplicate supported `(localProfileId, section)` key is dropped in full, while unrelated valid siblings continue. + +- [ ] **Step 7: Count raw preference presence and pass explicit first-page context** + +Add preference sections to `pageEntityCount`: + +```kotlin +val pageEntityCount = pullResponse.sessions.size + + pullResponse.routines.size + + pullResponse.cycles.size + + pullResponse.badges.size + + pullResponse.personalRecords.size + + (pullResponse.profilePreferenceSections?.size ?: 0) + + (if (pullResponse.rpgAttributes != null) 1 else 0) + + (if (pullResponse.gamificationStats != null) 1 else 0) + + pullResponse.externalActivities.size +``` + +Raw presence counts on every page even when migration is not Ready or the page is later ignored. This keeps the existing empty-page guard from truncating pagination before later ordinary entities. + +After `pagesProcessed++`, pass an explicit first-page flag rather than inferring permission inside the merge from DTO presence: + +```kotlin +val mergeResult = mergePullPage( + pullResponse = pullResponse, + lastSync = lastSync, + mergeProfileId = mergeProfileId, + isFirstPage = pagesProcessed == 1, +) + +private suspend fun mergePullPage( + pullResponse: PortalSyncPullResponse, + lastSync: Long, + mergeProfileId: String, + isFirstPage: Boolean, +): Result { +``` + +- [ ] **Step 8: Apply validated first-page sections before ordinary preparation** + +Add this helper to `SyncManager`: + +```kotlin +private suspend fun applyPulledProfilePreferences( + dtos: List?, + isFirstPage: Boolean, +) { + val sections = dtos.orEmpty() + if (sections.isEmpty()) return + if (!isFirstPage) { + Logger.w("SyncManager") { + profilePreferencePullCountLogLine( + ProfilePreferencePullDiagnosticCategory.LATER_PAGE_IGNORED, + sections.size, + ) + } + return + } + if (!isProfilePreferenceMigrationReady()) return + + val safeFailureLogger: (String) -> Unit = { line -> + Logger.w("SyncManager") { line } + } + val plan = isolateProfilePreferenceFailure( + ProfilePreferenceLocalFailureStage.PULL_RESPONSE_MAPPING, + safeFailureLogger, + ) { + planProfilePreferencePullSections(sections) + } ?: return + if (plan.invalidCanonicalCount > 0) { + Logger.w("SyncManager") { + profilePreferencePullCountLogLine( + ProfilePreferencePullDiagnosticCategory.INVALID_CANONICAL, + plan.invalidCanonicalCount, + ) + } + } + if (plan.duplicateKeyCount > 0) { + Logger.w("SyncManager") { + profilePreferencePullCountLogLine( + ProfilePreferencePullDiagnosticCategory.DUPLICATE_KEY, + plan.duplicateKeyCount, + ) + } + } + if (plan.valid.isEmpty()) return + + val report = isolateProfilePreferenceFailure( + ProfilePreferenceLocalFailureStage.PULL_APPLY, + safeFailureLogger, + ) { + profilePreferenceSyncRepository.applyPulledSections(plan.valid) + } ?: return + if (report.ignoredUnknownProfile > 0) { + Logger.i("SyncManager") { + profilePreferencePullCountLogLine( + ProfilePreferencePullDiagnosticCategory.UNKNOWN_PROFILE, + report.ignoredUnknownProfile, + ) + } + } + if (report.invalid > 0) { + Logger.w("SyncManager") { + profilePreferencePullCountLogLine( + ProfilePreferencePullDiagnosticCategory.REPOSITORY_INVALID, + report.invalid, + ) + } + } +} +``` + +Call it at the very start of `mergePullPage`, before session lookup/preparation and every ordinary merge: + +```kotlin +applyPulledProfilePreferences( + dtos = pullResponse.profilePreferenceSections, + isFirstPage = isFirstPage, +) +``` + +Only raw list size may be observed for pagination/later-page protocol diagnostics before migration `Ready`; do not decode or call the repository until the dynamic lambda returns true. Never inspect or apply `pullResponse.localProfiles`. Non-cancellation mapping/application failures stop only preference handling for this page; ordinary entities continue. Cancellation escapes unchanged. + +- [ ] **Step 9: Document and log the actual partial-commit boundary** + +Replace the `mergePullPage` KDoc and all-or-nothing page comments with this contract: + +```kotlin +/** + * Merge one pull page in preference-first order. + * + * Preference sections commit through ProfilePreferenceSyncRepository before ordinary + * entities. SyncRepository's ordinary merge owns its own transaction, while session LWW, + * preferences, notes, RPG, and external activities may use separate repository transactions. + * A later ordinary failure therefore does not roll back an earlier preference commit. + * The caller leaves lastSync unchanged and retries the page; revision guards make replay + * idempotent and dirty-section predicates preserve concurrent local edits. + */ +``` + +Change the ordinary failure log to avoid claiming that the entire page rolled back or that nothing persisted: + +```kotlin +Logger.e(e) { + "Ordinary pull merge failed; lastSync will not advance and earlier per-repository " + + "merges may remain for idempotent retry." +} +``` + +Also rename `// Merge this page atomically` in the pagination loop to `// Merge this page in preference-first repository order`. The approved semantics are explicit: + +- an ordinary merge failure retains an already committed preference canonical; +- the pull fails, `lastSync` does not advance, and `retryPull` replays from the prior checkpoint; +- replay is safe because pull queries require clean state and a non-regressing server revision; +- an isolated preference-local failure does not discard ordinary entities, and the backend reoffers the current canonical on the first page of a later sync; +- no Task 8 comment or log may claim page-wide all-or-nothing behavior. + +- [ ] **Step 10: Re-run the Task 4 persistence merge contract** + +Run the focused SQLDelight repository suite again after wiring pull. It must re-run Task 4's already-green contract: all-five matching-generation push application, all-five newer-generation preservation, row-owned LED/VBT columns, all-five clean+higher application and lower-revision rejection, dirty-pull preservation, normalized `80` versus `80.0` equality, true equal-revision repair, malformed-canonical valid-sibling isolation, canonical-null and canonical/outcome identity no-ops, safe canonical revision boundaries, category-only local dead letters for malformed JSON/unsafe integers/LED Int32/NUL/lone-surrogate/local-only keys, and generated-query unknown-profile no-create behavior. + +The concurrency authority remains the generated `applyPulled*WhenClean` predicates: `profile_id = ? AND *_dirty = 0 AND *_server_revision <= ?`. Do not add a manager-side profile or dirty-state precheck. An edit before the repository transaction makes the row dirty; an edit after canonical application advances generation/dirty state normally; SQLDelight serializes a simultaneous edit against the transaction. + +- [ ] **Step 11: Run pull, pagination, invariant, and repository tests** + +Run: + +```powershell +.\gradlew.bat :shared:testAndroidHostTest --tests "*SyncManagerProfilePreferencesTest*" --tests "*PortalPullPaginationTest*" --tests "*SyncInvariantCheckerTest*" --tests "*SqlDelightProfilePreferenceSyncRepositoryTest*" -Pskip.supabase.check=true +``` + +Expected: PASS. + +- [ ] **Step 12: Commit preference-first pull convergence** + +```powershell +git add shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/SyncManager.kt shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/SyncManagerProfilePreferencesTest.kt shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/PortalPullPaginationTest.kt shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeSyncRepository.kt +git commit -m "feat(sync): merge canonical profile preferences on pull" +``` + +--- + +### Task 9: Verify Compatibility, DI, Schema Dependency, and the Full Mobile Build + +**Files:** +- Modify: `shared/src/androidHostTest/kotlin/com/devil/phoenixproject/di/KoinModuleVerifyTest.kt` only if the existing verification test requires an explicit assertion for the new constructor dependency. +- Verify: all files changed by Tasks 1–8 and the completed data-foundation plan. + +**Interfaces:** +- Consumes: complete backend handoff, schema 43 foundation, and mobile sync implementation. +- Produces: a green, counted, privacy-audited Android/iOS sync/backend slice ready to integrate with consumer and UI plans. + +**Execution boundary:** Task 9 is mobile-repository verification only. Every Gradle command keeps `-Pskip.supabase.check=true`. Do not invoke Supabase CLI, a Supabase MCP/project integration, Dashboard actions, remote SQL, `db push`, migration repair, or Edge Function deployment. The external portal checklist after Task 9 is handoff documentation for a separately authorized portal-repository owner; it is not part of this task. + +- [ ] **Step 1: Require a clean tree and pin the committed verification range** + +The sync/backend implementation begins immediately after data-foundation commit `6ddf9031`. Verify that immutable baseline and require a clean worktree so committed Tasks 1–8 are not confused with verification-only repairs: + +```powershell +$syncBase = '6ddf9031' +git rev-parse --verify "$syncBase^{commit}" | Out-Null +if ($LASTEXITCODE -ne 0) { throw "Missing reviewed sync baseline $syncBase" } +$status = @(git status --porcelain) +if ($status.Count -ne 0) { + $status | ForEach-Object { Write-Host $_ } + throw 'Task 9 requires a clean worktree; finish or isolate Task 8 first' +} +$rangeFiles = @(git diff --name-only "$syncBase..HEAD") +if ($rangeFiles.Count -eq 0) { throw 'The committed Tasks 1-8 verification range is empty' } +"Task 9 range: $syncBase..HEAD ($($rangeFiles.Count) tracked paths)" +``` + +Expected: the baseline resolves, `git status --porcelain` emits nothing, and the range contains the committed backend handoff and mobile sync files. Do not replace the reviewed baseline with a moving branch name. + +- [ ] **Step 2: Run formatting checks without broad mutation** + +Run: + +```powershell +.\gradlew.bat spotlessCheck -Pskip.supabase.check=true +``` + +Expected: BUILD SUCCESSFUL. Task 9 is check-only: do not run broad `spotlessApply`. If formatting fails, return the named file to its Task 1–8 owner for a path-scoped formatting commit, restore a clean tree, and rerun this step. + +- [ ] **Step 3: Regenerate SQLDelight and verify the exact schema-43 dependency** + +Run: + +```powershell +.\gradlew.bat :shared:generateCommonMainVitruvianDatabaseInterface :shared:verifyCommonMainVitruvianDatabaseMigration :shared:validateSchemaManifest -Pskip.supabase.check=true +if ($LASTEXITCODE -ne 0) { throw 'SQLDelight/schema verification failed' } +$sharedBuild = Get-Content -Raw 'shared/build.gradle.kts' +if ($sharedBuild -notmatch 'version\s*=\s*43') { throw 'SQLDelight schema version is not 43' } +if (-not (Test-Path 'shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/migrations/42.sqm')) { + throw 'Required schema-42-to-43 migration 42.sqm is missing' +} +``` + +Expected: BUILD SUCCESSFUL, `Schema manifest validated: 389 columns across 46 tables, all covered.`, SQLDelight schema version 43, and tracked migration `42.sqm`. If a later reviewed schema change intentionally changes the manifest counts, update this expectation in the same schema-owning change rather than accepting silent drift. + +- [ ] **Step 4: Run the exact focused sync, transport, privacy, and invariant suite** + +Run: + +```powershell +.\gradlew.bat :shared:testAndroidHostTest ` + --tests "com.devil.phoenixproject.data.sync.BackendHandoffContractTest" ` + --tests "com.devil.phoenixproject.data.sync.ProfilePreferenceSyncDtosTest" ` + --tests "com.devil.phoenixproject.data.sync.ProfilePreferenceSyncPlannerTest" ` + --tests "com.devil.phoenixproject.data.sync.PortalSyncAdapterProfilePreferencesTest" ` + --tests "com.devil.phoenixproject.data.sync.PortalPullAdapterProfilePreferencesTest" ` + --tests "com.devil.phoenixproject.data.sync.PortalApiClientProfilePreferenceLimitsTest" ` + --tests "com.devil.phoenixproject.testutil.FakePortalApiClientTest" ` + --tests "com.devil.phoenixproject.data.sync.ErrorClassificationTest" ` + --tests "com.devil.phoenixproject.data.sync.ClientRateLimiterTest" ` + --tests "com.devil.phoenixproject.data.sync.SyncManagerProfilePreferencesTest" ` + --tests "com.devil.phoenixproject.data.sync.PortalPushLimitsTest" ` + --tests "com.devil.phoenixproject.data.sync.PortalPullPaginationTest" ` + --tests "com.devil.phoenixproject.data.sync.PortalTokenRefreshTest" ` + --tests "com.devil.phoenixproject.data.sync.SyncManagerTest" ` + --tests "com.devil.phoenixproject.data.sync.SyncInvariantCheckerTest" ` + -Pskip.supabase.check=true --console=plain +if ($LASTEXITCODE -ne 0) { throw 'Focused profile-preference sync suite failed' } +$focusedResults = @(Get-ChildItem 'shared/build/test-results/testAndroidHostTest' -Filter 'TEST-*.xml') +if ($focusedResults.Count -eq 0) { throw 'Focused suite produced no JUnit XML results' } +$focusedSuites = $focusedResults | ForEach-Object { [xml](Get-Content -Raw $_.FullName) } +$focusedTests = ($focusedSuites | ForEach-Object { [int]$_.testsuite.tests } | Measure-Object -Sum).Sum +$focusedFailures = ($focusedSuites | ForEach-Object { + [int]$_.testsuite.failures + [int]$_.testsuite.errors +} | Measure-Object -Sum).Sum +$focusedSkipped = ($focusedSuites | ForEach-Object { [int]$_.testsuite.skipped } | Measure-Object -Sum).Sum +if ($focusedFailures -ne 0 -or $focusedSkipped -ne 0) { + throw "Focused results are not clean: failures/errors=$focusedFailures skipped=$focusedSkipped" +} +"Focused profile-preference verification: $focusedTests tests, 0 failures/errors, 0 skipped" +``` + +Expected: every exact class filter resolves, the command exits 0, and the dynamic JUnit summary reports a positive test count with zero failures, errors, or skipped tests. Do not hard-code a test count that becomes stale when Task 8 adds cases. + +- [ ] **Step 5: Run repository race and migration-gate tests** + +Run: + +```powershell +.\gradlew.bat :shared:testAndroidHostTest ` + --tests "com.devil.phoenixproject.data.sync.SqlDelightProfilePreferenceSyncRepositoryTest" ` + --tests "com.devil.phoenixproject.data.repository.SqlDelightProfilePreferencesRepositoryTest" ` + --tests "com.devil.phoenixproject.data.migration.ProfilePreferencesMigrationTest" ` + --tests "com.devil.phoenixproject.data.migration.MigrationManagerTest" ` + -Pskip.supabase.check=true --console=plain +``` + +Expected: PASS, including accepted/conflict acknowledgement races against a newer local generation. + +- [ ] **Step 6: Run Koin graph verification** + +Run: + +```powershell +.\gradlew.bat :shared:testAndroidHostTest --tests "com.devil.phoenixproject.di.KoinModuleVerifyTest" -Pskip.supabase.check=true +``` + +Expected: PASS. If it fails on the new constructor boundary, bind the real `MigrationManager`, `ProfilePreferenceSyncCodec`, and `SqlDelightProfilePreferenceSyncRepository` in the verification graph rather than adding nullable production dependencies. + +- [ ] **Step 7: Run all shared Android-host tests and report dynamic totals** + +Run: + +```powershell +.\gradlew.bat :shared:testAndroidHostTest --continue -Pskip.supabase.check=true --console=plain +if ($LASTEXITCODE -ne 0) { throw 'Full shared Android-host suite failed' } +$allResults = @(Get-ChildItem 'shared/build/test-results/testAndroidHostTest' -Filter 'TEST-*.xml') +if ($allResults.Count -eq 0) { throw 'Full shared suite produced no JUnit XML results' } +$allSuites = $allResults | ForEach-Object { [xml](Get-Content -Raw $_.FullName) } +$allTests = ($allSuites | ForEach-Object { [int]$_.testsuite.tests } | Measure-Object -Sum).Sum +$allFailures = ($allSuites | ForEach-Object { + [int]$_.testsuite.failures + [int]$_.testsuite.errors +} | Measure-Object -Sum).Sum +$allSkipped = ($allSuites | ForEach-Object { [int]$_.testsuite.skipped } | Measure-Object -Sum).Sum +if ($allFailures -ne 0) { throw "Full shared results contain $allFailures failures/errors" } +"Full shared Android-host verification: $allTests tests, 0 failures/errors, $allSkipped skipped" +``` + +Expected: BUILD SUCCESSFUL and a positive dynamically computed test count with zero failures/errors. Report skipped tests rather than hiding them; any skipped profile-preference suite is blocking even if unrelated platform-conditional skips are accepted. + +- [ ] **Step 8: Compile iOS main and test targets** + +Use the same cross-host compile tasks as CI: + +```powershell +.\gradlew.bat :shared:compileKotlinIosArm64 :shared:compileTestKotlinIosArm64 -Pskip.supabase.check=true --console=plain +``` + +Expected: BUILD SUCCESSFUL with no missing iOS actual, serializer, SQLDelight query, or constructor dependency. This is compile verification only; it does not sign, link, install, or deploy an iOS app. + +- [ ] **Step 9: Run Android unit/lint checks and assemble the debug app** + +Run: + +```powershell +.\gradlew.bat :androidApp:testDebugUnitTest :androidApp:lintDebug :androidApp:assembleDebug --continue -Pskip.supabase.check=true --console=plain +``` + +Expected: BUILD SUCCESSFUL with Android unit tests and lint green and a debug APK under `androidApp/build/outputs/apk/debug/`. + +- [ ] **Step 10: Inspect committed and worktree diffs for scope, privacy, and wire regressions** + +Run: + +```powershell +$syncBase = '6ddf9031' +git diff --check "$syncBase..HEAD" +if ($LASTEXITCODE -ne 0) { throw 'Committed Tasks 1-8 diff has whitespace errors' } +git diff --check +if ($LASTEXITCODE -ne 0) { throw 'Verification worktree diff has whitespace errors' } + +$rangeFiles = @(git diff --name-only "$syncBase..HEAD") +$unexpected = @($rangeFiles | Where-Object { + $_ -notmatch '^docs/backend-handoff/' -and + $_ -ne 'docs/superpowers/plans/2026-07-11-profile-preferences-sync-backend.md' -and + $_ -ne 'gradle/libs.versions.toml' -and + $_ -ne 'shared/build.gradle.kts' -and + $_ -notmatch '^shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/' -and + $_ -ne 'shared/src/commonMain/kotlin/com/devil/phoenixproject/di/SyncModule.kt' -and + $_ -ne 'shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/VitruvianDatabase.sq' -and + $_ -notmatch '^shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/' -and + $_ -notmatch '^shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/(FakePortalApiClient(Test)?|FakeProfilePreferenceSyncRepository|FakeSyncRepository)\.kt$' -and + $_ -notmatch '^shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/sync/' -and + $_ -ne 'shared/src/androidHostTest/kotlin/com/devil/phoenixproject/di/KoinModuleVerifyTest.kt' +}) +if ($unexpected.Count -ne 0) { + $unexpected | ForEach-Object { Write-Host $_ } + throw 'Committed sync range contains paths outside the reviewed Task 1-9 scope' +} + +git diff --stat "$syncBase..HEAD" +git diff "$syncBase..HEAD" -- ` + docs/backend-handoff ` + gradle/libs.versions.toml ` + shared/build.gradle.kts ` + shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync ` + shared/src/commonMain/kotlin/com/devil/phoenixproject/di/SyncModule.kt ` + shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/VitruvianDatabase.sq ` + shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync ` + shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil ` + shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/sync ` + shared/src/androidHostTest/kotlin/com/devil/phoenixproject/di/KoinModuleVerifyTest.kt +git diff + +$forbiddenLocalOnly = '(?i)safeword|safewordcalibrated|adultsonlyconfirmed|adultsonlyprompted' +$privacyTargets = @( + 'shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync', + 'docs/backend-handoff/profile-preferences-supabase.sql', + 'docs/backend-handoff/profile-preference-byte-goldens.json', + 'docs/backend-handoff/profile-preferences-edge-functions.md' +) +$privacyFiles = foreach ($target in $privacyTargets) { + if (Test-Path $target -PathType Container) { + Get-ChildItem $target -Recurse -File + } else { + Get-Item $target + } +} +$privacyHits = @($privacyFiles | Select-String -Pattern $forbiddenLocalOnly) +$approvedDenylistFiles = @{ + (Resolve-Path 'shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/PortalSyncDtos.kt').Path = 4 + (Resolve-Path 'docs/backend-handoff/profile-preferences-edge-functions.md').Path = 4 +} +$exactNormalizedDenylistLiteral = '^\s*"(safeword|safewordcalibrated|adultsonlyconfirmed|adultsonlyprompted)",\s*$' +$unexpectedPrivacyHits = @($privacyHits | Where-Object { + -not $approvedDenylistFiles.ContainsKey($_.Path) -or + $_.Line -notmatch $exactNormalizedDenylistLiteral +}) +foreach ($entry in $approvedDenylistFiles.GetEnumerator()) { + $count = @($privacyHits | Where-Object { $_.Path -eq $entry.Key }).Count + if ($count -ne $entry.Value) { + throw "Expected $($entry.Value) normalized denylist literals in $($entry.Key), found $count" + } +} +if ($unexpectedPrivacyHits.Count -ne 0) { + $unexpectedPrivacyHits | ForEach-Object { Write-Host "$($_.Path):$($_.LineNumber)" } + throw 'Local-only safety/consent name escaped the two approved rejection denylists' +} +$mobileSecretHits = @(Get-ChildItem 'shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync' -File | + Select-String -Pattern 'SUPABASE_SERVICE_ROLE_KEY|serviceRoleKey') +if ($mobileSecretHits.Count -ne 0) { throw 'A service-role secret identifier entered mobile sync code' } +``` + +Expected: both whitespace checks emit nothing; every committed path is in the allowlist; the full `6ddf9031..HEAD` feature diff—not merely the clean worktree—is reviewed. The four local-only safety/consent names appear only as eight exact lowercase string literals in the approved mobile and Edge rejection denylists—never as DTO fields or payload values—and service-role secret identifiers produce zero mobile-code hits. Confirm manually in that diff that `localGeneration` is never serialized, preference-only bodies contain no active profile metadata or ordinary entities, diagnostics contain fixed categories/counts only, and no service-role value enters mobile code. + +- [ ] **Step 11: Confirm local-only handoff scope** + +Task 9 must finish without creating or changing any portal checkout, migration, advisor disposition, remote branch, database object, or Edge Function. Verify the mobile feature range itself contains no portal-repository target: + +```powershell +$syncBase = '6ddf9031' +$portalTargets = @(git diff --name-only "$syncBase..HEAD" | Where-Object { $_ -match '^supabase/' }) +if ($portalTargets.Count -ne 0) { + $portalTargets | ForEach-Object { Write-Host $_ } + throw 'Mobile Task 9 must not contain portal-repository Supabase changes' +} +``` + +Expected: no output. Do not run any command from the external portal acceptance checklist below. + +- [ ] **Step 12: Commit only a verified Koin-test adjustment, if one was required** + +If and only if Step 6 proved that `KoinModuleVerifyTest.kt` needed a verification-only change, require it to be the sole dirty path before committing: + +```powershell +$allowed = 'shared/src/androidHostTest/kotlin/com/devil/phoenixproject/di/KoinModuleVerifyTest.kt' +$dirty = @(git status --porcelain | ForEach-Object { $_.Substring(3).Replace('\', '/') }) +if ($dirty.Count -eq 0) { + Write-Host 'No Task 9 verification adjustment to commit' +} elseif ($dirty.Count -eq 1 -and $dirty[0] -eq $allowed) { + git add -- $allowed + git commit -m "test(sync): verify profile preference integration" +} else { + $dirty | ForEach-Object { Write-Host $_ } + throw 'Task 9 will not commit formatting or implementation changes owned by Tasks 1-8' +} +``` + +If Step 6 required no file changes, do not create an empty commit. After any Koin-test commit, rerun Steps 2, 6, and 10. + +--- + +## External Portal Handoff Acceptance Checklist (Separate Authorization; Not Task 9) + +This checklist is a backend handoff for a portal owner working in the portal repository after separate explicit authorization. It does not authorize the mobile implementation agent to install or invoke Supabase tooling, access a Supabase project, execute SQL, use Dashboard/MCP actions, create a remote migration, deploy functions, or change external state. Task 9 ends before this checklist. A future portal implementer must record all of these results before the mobile release is enabled: + +- [ ] The real portal migration was created with `supabase migration new profile_preferences` and the reviewed mobile SQL was copied without weakening grants, constraints, RLS, or RPC execution privileges. +- [ ] `supabase start` precedes `supabase db reset`, `supabase migration list`, `supabase test db`, function tests, lint, and advisors in a disposable/local environment. +- [ ] Supabase security and performance advisors were run; every finding is fixed or documented with a concrete disposition. +- [ ] Direct `anon` and `authenticated` table SELECT/INSERT/UPDATE/DELETE fail under normal grants. +- [ ] Temporary-grant RLS tests prove owner SELECT/INSERT/UPDATE/DELETE and cross-owner `user_id` reassignment rejection in an isolated transaction; they do not claim same-owner `local_profile_id` immutability. +- [ ] Edge tests prove the JWT-derived user can mutate only that user's composite-key rows; returned 400/401/403 Auth errors map to 401 while operational/malformed/thrown auth failures map to generic 503 with name-only logs and zero admin construction. +- [ ] Cross-user `localProfileId`, forged revisions/timestamps, unsupported versions, malformed payloads, exact raw section spans over 256 KiB, and complete original-byte requests over 512 KiB whenever the preference field is present are rejected using the shared cross-language goldens. +- [ ] Raw-handler tests reject BOM and malformed UTF-8 before admin, use original `Uint8Array.byteLength` for caps, and accept valid U+FFFD. +- [ ] Int32, Float32 overflow/nonzero-underflow, recursive PostgreSQL Unicode scalar, and strict timezone-bearing RFC3339 boundary matrices pass in push/RPC/pull paths. +- [ ] `BackendHandoffContractTest` contains a reviewed non-sentinel SHA-256 literal sealing the complete LF-normalized Edge handoff; appended/inverted executable mutations fail it. +- [ ] Same-section concurrent first writes produce one revision-1 winner and one canonical conflict. +- [ ] Different-section concurrent first writes both succeed at revision 1 without overwriting either section. +- [ ] Lost-ack retry converges through a canonical revision conflict. +- [ ] Pull emits preference canonicals only on its first page and retains the existing required `syncTime`. +- [ ] Database migration and both Edge Functions deploy before the mobile release begins preference sync. diff --git a/docs/superpowers/plans/2026-07-11-profile-tab-ui-navigation.md b/docs/superpowers/plans/2026-07-11-profile-tab-ui-navigation.md new file mode 100644 index 000000000..c093d3938 --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-profile-tab-ui-navigation.md @@ -0,0 +1,7925 @@ +# Profile Tab UI, Navigation, and Insights Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add the fifth Profile root tab, shared long-press profile switcher, profile management and preferences screen, profile-correct compact exercise insights, and a pruned global Settings screen while removing the legacy Home and Just Lift profile panels. + +**Architecture:** `EnhancedMainScreen` remains the owner of root navigation and the shared profile-switcher overlay, while a route-scoped `ProfileViewModel` consumes the data-foundation plan's `ActiveProfileContext` and typed profile-preference APIs. A shared `ResolveCurrentOneRepMaxUseCase` supplies profile-scoped 1RM precedence to both Profile and Exercise Detail; focused presentation-only components hold identity dialogs, switcher rows, compact insights, and preference groups so `ProfileScreen` and `SettingsTab` do not become monoliths. + +**Tech Stack:** Kotlin Multiplatform, Compose Multiplatform Material 3, Navigation Compose, AndroidX Lifecycle ViewModel, Koin, Kotlin coroutines/StateFlow, SQLDelight repository queries, Compose Resources XML, kotlin.test/JUnit4, Gradle 9.5.1. + +## Global Constraints + +- Complete `docs/superpowers/plans/2026-07-11-profile-preferences-data-foundation.md` first. This plan consumes its exact profile types and persistence behavior; it must not recreate a second preference store. +- The approved bottom-bar order is Analytics, Insights, Home, Profile, Settings. +- The bar remains icon-only. Profile uses `Icons.Default.Person`. +- At 320dp width, every one of the five equal-width cells must retain a clickable area at least 48dp high and 48dp wide; outer horizontal padding is at most 8dp and the old inner `widthIn(min = 64.dp)` is removed. +- A normal Profile-item tap navigates with the existing root-tab save/restore behavior. A long press performs long-press haptic feedback and opens the switcher without navigating. +- Because `clearAndSetSemantics` replaces generated semantics, the Profile item must explicitly expose both semantic click and semantic long-click actions with localized labels. +- `EnhancedMainScreen` owns one `ProfileSwitcherSheet` instance accessible from every route where the root bottom bar is visible. `ProfileScreen`'s visible Switch Profile action opens that same instance. +- The switcher lists and selects existing profiles and opens Add Profile. It contains no rename or delete actions. +- Creating a profile uses the data-foundation plan's atomic create-with-defaults-and-activate operation. The creation UI closes only after success. +- Rename/color and guarded delete live in the active profile header. The Default profile cannot be deleted. +- Do not expose Profile delete until the data-foundation plan's overlapping PR/badge collision merge tests pass. +- Profile switching is unavailable after leaving a root tab for Just Lift or another workout flow. Remove both legacy side-panel call sites rather than adding a replacement inside workout routes. +- Use a route-scoped `ProfileViewModel`; do not add Profile selection/insight state to `MainViewModel`. +- Exercise selection is an in-memory map keyed by profile ID for the lifetime of the Profile root navigation entry. It is not persisted through backup, SQL, or process death. +- On profile change, clear rendered exercise data while the new `ActiveProfileContext` is `Switching`; restore a still-valid saved selection for that profile, otherwise resolve that profile's most recent completed exercise. +- The current-1RM precedence is latest passing valid velocity estimate, then the latest profile-scoped assessment's first valid override/estimate, then the newest valid estimate among at most five recent profile-scoped completed sessions through `OneRepMaxCalculator.estimate`. +- Velocity and session values are per-cable kilograms. Assessment result and override values are total kilograms and must be divided by two before the shared resolver returns them. +- Never use `Exercise.oneRepMaxKg` as the Profile/Exercise Detail resolver fallback. +- Assessment save APIs require an explicit Ready profile ID; remove silent `"default"` parameters from assessment persistence. +- Historical velocity estimates and assessment actions remain visible when `vbtEnabled` is false. +- PR highlights show the all-time max-weight, estimated-1RM, and max-volume values for the selected exercise and active profile. +- Recent history contains at most five completed, non-deleted sessions for the selected exercise and active profile. +- `ExercisePickerDialog` remains the searchable/filterable selector and is called with `enableCustomExercises = false`. +- Settings retains Portal/cloud sync, theme/dynamic color, language, video behavior, integrations, backup/restore/destination/destructive data management, BLE compatibility/logs/diagnostics/developer tools, donation, and app information. +- Settings removes weight unit/increment, body weight, Equipment Rack entry, workout behavior, audio controls, gamification, Achievements entry, LED, VBT, verbal feedback, voice-stop, safe-word, and adult-mode controls. +- The global Show Exercise Videos control currently nested inside Workout Preferences must be extracted and retained before that card is removed. +- `velocityOneRepMaxBackfillDone` remains an internal global/device migration flag and is not presented as a profile preference. +- `discoModeActive` remains transient BLE state. Persisted LED choices belong to the active profile; the sound/haptic preview can continue through MainViewModel's existing event stream. +- UI state never encodes or edits raw JSON. It consumes typed values and invokes typed data-foundation callbacks. +- A failed mutation leaves repository state authoritative, dismisses no destructive dialog, and emits one concise localized snackbar error. +- Add no new runtime dependency or Compose UI-test framework for this feature. Use ViewModel/use-case/repository tests plus narrow source-contract guards for gesture wiring and source removal. +- Add new user-visible strings to default, Dutch, German, Spanish, and French Compose resource files. Italian remains an unsupported partial fallback because it is not offered by the current language selector. +- Backend SQL/Edge implementation is outside this plan. + +--- + + + + + +## Dependency Contract from the Data-Foundation Plan + +Do not begin Task 5 until the data-foundation implementation provides these names and semantics: + +```kotlin +sealed interface ActiveProfileContext { + data class Switching(val targetProfileId: String?) : ActiveProfileContext + + data class Ready( + val profile: UserProfile, + val preferences: UserProfilePreferences, + val localSafety: ProfileLocalSafetyPreferences, + ) : ActiveProfileContext +} + +class ProfileContextRecoveryException(cause: Throwable) : + IllegalStateException("Could not reconcile the active profile context", cause) + +interface UserProfileRepository { + val activeProfile: StateFlow + val allProfiles: StateFlow> + val activeProfileContext: StateFlow + + fun observePreferences(profileId: String): Flow + + suspend fun createAndActivateProfile(name: String, colorIndex: Int): UserProfile + suspend fun updateProfile(id: String, name: String, colorIndex: Int) + suspend fun deleteProfile(id: String): Boolean + suspend fun setActiveProfile(id: String) + + suspend fun updateCore(profileId: String, value: CoreProfilePreferences) + suspend fun updateRack(profileId: String, value: RackPreferences) + suspend fun updateWorkout(profileId: String, value: WorkoutPreferences) + suspend fun updateLed(profileId: String, value: LedPreferences) + suspend fun updateVbt(profileId: String, value: VbtPreferences) + suspend fun updateLocalSafety(profileId: String, value: ProfileLocalSafetyPreferences) + suspend fun reconcileActiveProfileContext() +} +``` + +Every presentation mutation passes the `Ready.profile.id` captured with the edited value. The repository serializes switch/update operations and rejects that write if the ID is no longer active, preventing a stale A screen from writing into B. Presentation code catches that rejection, keeps the observed Ready value authoritative, and emits a localized failure event. Do not introduce adapters that write the legacy global preference store. + +## File Structure + +### Create + +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCase.kt` — shared profile-scoped 1RM precedence and unit normalization. +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileViewModel.kt` — route-scoped identity, per-profile exercise selection, independent insights state, typed preference mutation, and snackbar events. +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ProfileScreen.kt` — one scrolling Profile surface and dialog/snackbar orchestration. +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileIdentityComponents.kt` — color palette, avatar, and switcher row extracted from obsolete speed-dial code. +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileDialogs.kt` — callback-driven add, edit, delete, and blocking profile-context recovery dialogs. +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileSwitcherSheet.kt` — switch/create-only shared Material 3 bottom sheet. +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileExerciseInsights.kt` — selector row, 1RM source, PR highlights, five-session list, and compact volume chart. +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfilePreferenceComponents.kt` — measurements, rack, workout behavior, LED, VBT, and safety cards with typed state/callbacks. +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileSafetyDialogs.kt` — safe-word calibration, adult confirmation, Dominatrix unlock, and Disco unlock dialogs moved out of Settings. +- `shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCaseTest.kt` — resolver precedence, normalization, invalid-value, and profile-isolation tests. +- `shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailOneRepMaxLoadTest.kt` — latest-request token, real cancellation, ordinary-error, and legacy-read guards. +- `shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileViewModelTest.kt` — profile-selection restoration and independent insight/mutation-state tests. +- `shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/viewmodel/AssessmentViewModelProfileScopeTest.kt` — explicit assessment profile propagation. +- `shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/navigation/AssessmentProfileOwnershipTest.kt` — immutable route ownership, Ready-gated callsites, and A→Switching→B invalidation. +- `shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/AssessmentResourceContractTest.kt` — localized assessment-save failure copy across selectable locales. +- `shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/navigation/ProfileNavigationContractTest.kt` — five-item order, tap/long-press wiring, and navigation/recovery resource consumption. +- `shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ProfileSettingsSeparationContractTest.kt` — Settings pruning, retained-video resources, and obsolete-selector source guard. +- `shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ProfileResourceContractTest.kt` — exact 56-key inventory, reused-copy presence, placeholder/XML parity, selectable locales, and cross-target source-reader paths. +- `shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ProfileScreenContractTest.kt` — picker/chart/history behavior plus complete Profile insight/preference resource consumption. +- `shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/components/ProfileIdentityPolicyTest.kt` — palette wrapping, Default-delete policy, and switcher/delete-copy resource consumption. +- `shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeAssessmentRepository.kt` — controllable assessment saves and latest-read failures for resolver/ViewModel tests. + +### Modify + +- `shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/VitruvianDatabase.sq` — assessment 1RM compare-and-set compensation plus profile/exercise-limited completed-session queries. +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/WorkoutRepository.kt` — expose most-recent exercise and limited recent-session reads. +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/SqlDelightWorkoutRepository.kt` — map the new SQLDelight reads. +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/AssessmentRepository.kt` — make profile ID required. +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/SqlDelightAssessmentRepository.kt` — preserve explicit profile ownership and serialize/compensate multi-write assessment saves. +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/assessment/AssessmentEngine.kt` — keep assessment regression and clamp values in total kilograms. +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/AssessmentViewModel.kt` — use a safe profile-independent first load and forward an explicit Ready profile ID. +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/AssessmentWizardScreen.kt` — bind Results acceptance to the explicit route profile and hide the unscoped global 1RM. +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailScreen.kt` — pass explicit assessment ownership through its callback, then replace split session/velocity hero logic with the shared resolver. +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/AnalyticsScreen.kt` — retain its callback-only assessment entry point while NavGraph supplies an owner-bearing route. +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/navigation/NavigationRoutes.kt` — immutable profile-bearing assessment routes, Profile route, and canonical five-item order. +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/navigation/NavGraph.kt` — Profile destination/callbacks and explicit assessment profile wiring. +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/EnhancedMainScreen.kt` — five-cell bar, accessible long press, shared switcher owner, localized title. +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/SettingsTab.kt` — retain only global settings and compose the extracted global video card. +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/JustLiftScreen.kt` — remove profile selector state and overlay. +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/RoutinesTab.kt` — consume the extracted palette symbol. +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileListItem.kt` — switcher-focused tap semantics and disabled state. +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/util/TestTags.kt` — Profile navigation/screen/sheet/action tags. +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/di/DataModule.kt` — unchanged repository ownership but verified against new repository contracts. +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/di/DomainModule.kt` — shared resolver binding. +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/di/PresentationModule.kt` — ProfileViewModel binding. +- `shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeWorkoutRepository.kt` — bounded deterministic session reads, request capture, and read failures. +- `shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeVelocityOneRepMaxRepository.kt` — injectable latest-read failure. +- `shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeUserProfileRepository.kt` — data-foundation identity, context, captured-ID update, and injectable-failure APIs. +- `shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakePersonalRecordRepository.kt` — controllable insight-read failure. +- `shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/navigation/NavigationRoutesTest.kt` — assessment route ownership/encoding and blank-ID rejection. +- `shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightWorkoutRepositoryTest.kt` — completed-session query filters and limit. +- `shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightAssessmentRepositoryTest.kt` — explicit IDs, unit contract, and isolation. +- `shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/assessment/AssessmentEngineTest.kt` — total-kilogram hardware ceiling. +- `shared/src/commonMain/composeResources/values/strings.xml` — English Profile/navigation/insights/preferences/errors copy. +- `shared/src/commonMain/composeResources/values-nl/strings.xml` — Dutch copy. +- `shared/src/commonMain/composeResources/values-de/strings.xml` — German copy. +- `shared/src/commonMain/composeResources/values-es/strings.xml` — Spanish copy. +- `shared/src/commonMain/composeResources/values-fr/strings.xml` — French copy. + +### Delete after extraction and call-site removal + +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileSidePanel.kt` +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileSpeedDial.kt` +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/EditProfileDialog.kt` +- `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/DeleteProfileDialog.kt` + +--- + +### Task 1: Require Explicit Profile Ownership for Assessments + +**Files:** +- Create: `shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeAssessmentRepository.kt` +- Create: `shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/viewmodel/AssessmentViewModelProfileScopeTest.kt` +- Create: `shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/navigation/AssessmentProfileOwnershipTest.kt` +- Create: `shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/AssessmentResourceContractTest.kt` +- Modify: `shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/VitruvianDatabase.sq:2004-2006` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/AssessmentRepository.kt:38-95` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/SqlDelightAssessmentRepository.kt:56-171` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/assessment/AssessmentEngine.kt:46-52` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/AssessmentViewModel.kt:270-321` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/AssessmentWizardScreen.kt:66-129` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailScreen.kt:27-58,167-175` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/AnalyticsScreen.kt:48-57,97-101` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/navigation/NavigationRoutes.kt:49-53` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/navigation/NavGraph.kt:685-760` +- Modify: `shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/navigation/NavigationRoutesTest.kt:1-22` +- Modify: `shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightAssessmentRepositoryTest.kt:26-58` +- Modify: `shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/assessment/AssessmentEngineTest.kt:119-134` +- Modify: `shared/src/commonMain/composeResources/values/strings.xml` +- Modify: `shared/src/commonMain/composeResources/values-nl/strings.xml` +- Modify: `shared/src/commonMain/composeResources/values-de/strings.xml` +- Modify: `shared/src/commonMain/composeResources/values-es/strings.xml` +- Modify: `shared/src/commonMain/composeResources/values-fr/strings.xml` + +**Interfaces:** +- Consumes: the data-foundation plan's `ActiveProfileContext` stream. Every navigation call is gated on `Ready` and writes that profile ID into the route; the destination reads only this immutable route argument as its owner. +- Produces: `AssessmentViewModel.acceptResult(profileId: String, overrideKg: Float? = null)`, `AssessmentUiEvent.SaveFailed`, and assessment repository/entity APIs whose `profileId: String` arguments have no default value. +- Unit contract: wizard loads, assessment estimates, and overrides are total kilograms; `WorkoutSession.weightPerCableKg` and `Exercise.oneRepMaxKg` are per-cable kilograms. Persistence divides only the session average and exercise 1RM, never the assessment row. +- Lifecycle contract: `Ready(A)` may render only an A-owned route. `Switching` or `Ready(B)` invalidates and pops it before a B-owned wizard can be constructed; no asynchronous effect captures mutable active-profile state as route ownership. +- Failure contract: non-cancellation saves restore `Results` and emit exactly one localized `SaveFailed` event. Cancellation is rethrown. Repository saves are serialized per repository; compensation runs in `NonCancellable`, restores an attempted exercise 1RM only by SQL compare-and-set, and never overwrites a newer value. + +- [ ] **Step 1: Create a recording fake assessment repository** + +```kotlin +package com.devil.phoenixproject.testutil + +import com.devil.phoenixproject.data.repository.AssessmentRepository +import com.devil.phoenixproject.data.repository.AssessmentResultEntity +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +class FakeAssessmentRepository : AssessmentRepository { + data class SavedSession( + val exerciseId: String, + val estimatedOneRepMaxTotalKg: Float, + val userOverrideTotalKg: Float?, + val weightPerCableKg: Float, + val profileId: String, + ) + + val attemptedSessions = mutableListOf() + val savedSessions = mutableListOf() + var saveSessionFailure: Throwable? = null + private val assessments = mutableListOf() + + override suspend fun saveAssessment( + exerciseId: String, + estimatedOneRepMaxKg: Float, + loadVelocityDataJson: String, + sessionId: String?, + userOverrideKg: Float?, + profileId: String, + ): Long { + require(profileId.isNotBlank()) + val id = assessments.size.toLong() + 1L + assessments += AssessmentResultEntity( + id = id, + exerciseId = exerciseId, + estimatedOneRepMaxKg = estimatedOneRepMaxKg, + loadVelocityData = loadVelocityDataJson, + assessmentSessionId = sessionId, + userOverrideKg = userOverrideKg, + createdAt = id, + profileId = profileId, + ) + return id + } + + override fun getAssessmentsByExercise( + exerciseId: String, + profileId: String, + ): Flow> = flowOf( + assessments.filter { it.exerciseId == exerciseId && it.profileId == profileId } + .sortedByDescending { it.createdAt }, + ) + + override suspend fun getLatestAssessment( + exerciseId: String, + profileId: String, + ): AssessmentResultEntity? = assessments + .filter { it.exerciseId == exerciseId && it.profileId == profileId } + .maxByOrNull { it.createdAt } + + override suspend fun deleteAssessment(id: Long) { + assessments.removeAll { it.id == id } + } + + override suspend fun saveAssessmentSession( + exerciseId: String, + exerciseName: String, + estimatedOneRepMaxKg: Float, + loadVelocityDataJson: String, + userOverrideKg: Float?, + totalReps: Int, + durationMs: Long, + weightPerCableKg: Float, + profileId: String, + ): String { + require(profileId.isNotBlank()) + val captured = SavedSession( + exerciseId = exerciseId, + estimatedOneRepMaxTotalKg = estimatedOneRepMaxKg, + userOverrideTotalKg = userOverrideKg, + weightPerCableKg = weightPerCableKg, + profileId = profileId, + ) + attemptedSessions += captured + saveSessionFailure?.let { throw it } + savedSessions += captured + saveAssessment( + exerciseId = exerciseId, + estimatedOneRepMaxKg = estimatedOneRepMaxKg, + loadVelocityDataJson = loadVelocityDataJson, + sessionId = "assessment-session-${savedSessions.size}", + userOverrideKg = userOverrideKg, + profileId = profileId, + ) + return "assessment-session-${savedSessions.size}" + } +} +``` + +The fake records an attempted call before the failure seam and records a successful save only afterward. This distinction is required for the ordinary-error and cancellation tests; do not make a thrown save look committed. + +- [ ] **Step 2: Write the failing ViewModel profile-propagation test** + +```kotlin +package com.devil.phoenixproject.presentation.viewmodel + +import app.cash.turbine.test +import com.devil.phoenixproject.domain.assessment.AssessmentEngine +import com.devil.phoenixproject.domain.model.Exercise +import com.devil.phoenixproject.testutil.FakeAssessmentRepository +import com.devil.phoenixproject.testutil.FakeExerciseRepository +import com.devil.phoenixproject.testutil.TestCoroutineRule +import kotlin.coroutines.cancellation.CancellationException +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class AssessmentViewModelProfileScopeTest { + @get:Rule + val coroutineRule = TestCoroutineRule() + + @Test + fun acceptResult_forwardsExplicitProfileAndPreservesUnitBoundary() = runTest { + val assessmentRepository = FakeAssessmentRepository() + val viewModel = readyViewModel(assessmentRepository) + reachResults(viewModel) + + viewModel.acceptResult(profileId = "athlete-a", overrideKg = 120f) + advanceUntilIdle() + + val saved = assessmentRepository.savedSessions.single() + assertEquals("athlete-a", saved.profileId) + assertEquals(120f, saved.userOverrideTotalKg) + assertEquals(30f, saved.weightPerCableKg) + assertIs(viewModel.currentStep.value) + assertEquals(120f, (viewModel.currentStep.value as AssessmentStep.Complete).finalOneRepMaxKg) + } + + @Test + fun acceptResult_rejectsBlankProfileBeforeStartingSave() = runTest { + val assessmentRepository = FakeAssessmentRepository() + val viewModel = readyViewModel(assessmentRepository) + reachResults(viewModel) + + assertFailsWith { + viewModel.acceptResult(profileId = " ") + } + + assertIs(viewModel.currentStep.value) + assertEquals(emptyList(), assessmentRepository.attemptedSessions) + } + + @Test + fun ordinarySaveFailureRestoresResultsAndEmitsOneEvent() = runTest { + val assessmentRepository = FakeAssessmentRepository().apply { + saveSessionFailure = IllegalStateException("test failure") + } + val viewModel = readyViewModel(assessmentRepository) + reachResults(viewModel) + + viewModel.events.test { + viewModel.acceptResult(profileId = "athlete-a") + advanceUntilIdle() + + assertEquals(AssessmentUiEvent.SaveFailed, awaitItem()) + expectNoEvents() + assertIs(viewModel.currentStep.value) + assertEquals("athlete-a", assessmentRepository.attemptedSessions.single().profileId) + assertEquals(emptyList(), assessmentRepository.savedSessions) + } + } + + @Test + fun cancellationIsNotConvertedIntoRetryOrFailureEvent() = runTest { + val assessmentRepository = FakeAssessmentRepository().apply { + saveSessionFailure = CancellationException("test cancellation") + } + val viewModel = readyViewModel(assessmentRepository) + reachResults(viewModel) + + viewModel.events.test { + viewModel.acceptResult(profileId = "athlete-a") + advanceUntilIdle() + + expectNoEvents() + assertIs(viewModel.currentStep.value) + assertEquals("athlete-a", assessmentRepository.attemptedSessions.single().profileId) + } + } + + @Test + fun startingLoad_isProfileIndependentWhenGlobalExerciseOneRmBelongsToAnotherProfile() = runTest { + val sharedExercises = exerciseRepository(oneRepMaxKg = 100f) + val profileA = readyViewModel(FakeAssessmentRepository(), sharedExercises) + val profileB = readyViewModel(FakeAssessmentRepository(), sharedExercises) + advanceUntilIdle() + + profileA.selectExerciseById("bench") + profileB.selectExerciseById("bench") + advanceUntilIdle() + + assertEquals( + 20f, + (profileA.currentStep.value as AssessmentStep.ProgressiveLoading).suggestedWeightKg, + ) + assertEquals( + 20f, + (profileB.currentStep.value as AssessmentStep.ProgressiveLoading).suggestedWeightKg, + ) + } + + private fun readyViewModel( + assessmentRepository: FakeAssessmentRepository, + exerciseRepository: FakeExerciseRepository = exerciseRepository(), + ): AssessmentViewModel { + return AssessmentViewModel( + exerciseRepository = exerciseRepository, + assessmentRepository = assessmentRepository, + assessmentEngine = AssessmentEngine(), + ) + } + + private fun exerciseRepository(oneRepMaxKg: Float? = null) = + FakeExerciseRepository().apply { + addExercise( + Exercise( + id = "bench", + name = "Bench Press", + muscleGroup = "Chest", + equipment = "BAR", + oneRepMaxKg = oneRepMaxKg, + ), + ) + } + + private suspend fun TestScope.reachResults(viewModel: AssessmentViewModel) { + advanceUntilIdle() + viewModel.selectExerciseById("bench") + advanceUntilIdle() + viewModel.recordSet(40f, 3, 1.0f, 1.1f) + viewModel.recordSet(80f, 3, 0.25f, 0.30f) + assertIs(viewModel.currentStep.value) + } +} +``` + +This test deliberately gives the shared `Exercise` row a 100 kg per-cable global value, then opens independent A and B route ViewModels. Both must display exactly the same conservative 20 kg total first load because the global column has no profile owner. + +- [ ] **Step 3: Write failing route-ownership, resource, and total-ceiling tests** + +Create `AssessmentProfileOwnershipTest.kt`: + +```kotlin +package com.devil.phoenixproject.presentation.navigation + +import com.devil.phoenixproject.data.repository.ActiveProfileContext +import com.devil.phoenixproject.testutil.FakeUserProfileRepository +import com.devil.phoenixproject.testutil.readProjectFile +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull + +class AssessmentProfileOwnershipTest { + @Test + fun `route owned by A invalidates through switching and Ready B without binding B`() { + val states = listOf( + ready("athlete-a"), + ActiveProfileContext.Switching("athlete-b"), + ready("athlete-b"), + ).map { context -> + resolveAssessmentProfileDestination( + routeProfileId = "athlete-a", + context = context, + ) + } + + assertEquals( + listOf( + AssessmentProfileDestinationState.Bound("athlete-a"), + AssessmentProfileDestinationState.Invalidated, + AssessmentProfileDestinationState.Invalidated, + ), + states, + ) + assertFalse( + states.any { it == AssessmentProfileDestinationState.Bound("athlete-b") }, + ) + } + + @Test + fun `all assessment entry points pass Ready profile IDs into route factories`() { + val navGraph = readProjectFile( + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/navigation/NavGraph.kt", + ) + val analytics = readProjectFile( + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/AnalyticsScreen.kt", + ) + val detail = readProjectFile( + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailScreen.kt", + ) + val wizard = readProjectFile( + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/AssessmentWizardScreen.kt", + ) + val assessmentViewModel = readProjectFile( + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/AssessmentViewModel.kt", + ) + assertNotNull(navGraph) + assertNotNull(analytics) + assertNotNull(detail) + assertNotNull(wizard) + assertNotNull(assessmentViewModel) + + assertContains(navGraph, "NavigationRoutes.StrengthAssessmentPicker.createRoute(profileId)") + assertContains(navGraph, "NavigationRoutes.StrengthAssessment.createRoute(profileId, exerciseId)") + assertFalse(navGraph.contains("navigate(NavigationRoutes.StrengthAssessmentPicker.route)")) + assertContains(analytics, "assessmentProfileId: String?") + assertContains(analytics, "onNavigateToStrengthAssessment: (String) -> Unit") + assertContains(detail, "assessmentProfileId: String?") + assertContains(detail, "onNavigateToStrengthAssessment: (String) -> Unit") + assertFalse(detail.contains("NavigationRoutes.StrengthAssessment")) + assertFalse(wizard.contains("exercise.oneRepMaxKg")) + assertFalse(assessmentViewModel.contains("exercise.oneRepMaxKg")) + } + + private fun ready(profileId: String): ActiveProfileContext.Ready { + val repository = FakeUserProfileRepository() + repository.setActiveProfileForTest(profileId) + return repository.activeProfileContext.value as ActiveProfileContext.Ready + } +} +``` + +Extend the existing `NavigationRoutesTest.kt` with route-factory ownership, encoding, and guard coverage: + +```kotlin +@Test +fun strengthAssessmentRoutes_encodeImmutableProfileOwnership() { + assertEquals( + "strength_assessment_picker/athlete%2FA", + NavigationRoutes.StrengthAssessmentPicker.createRoute("athlete/A"), + ) + assertEquals( + "strength_assessment/athlete%2FA/bench%20press", + NavigationRoutes.StrengthAssessment.createRoute("athlete/A", "bench press"), + ) +} + +@Test +fun strengthAssessmentRoutes_rejectBlankOwnership() { + assertFailsWith { + NavigationRoutes.StrengthAssessmentPicker.createRoute(" ") + } + assertFailsWith { + NavigationRoutes.StrengthAssessment.createRoute(" ", "bench") + } + assertFailsWith { + NavigationRoutes.StrengthAssessment.createRoute("athlete-a", " ") + } +} +``` + +Import `kotlin.test.assertFailsWith`. These factories are the only legal way to navigate to either assessment route; there is no profile-less overload or constant picker destination. + +Create `AssessmentResourceContractTest.kt`: + +```kotlin +package com.devil.phoenixproject.presentation.screen + +import com.devil.phoenixproject.testutil.readProjectFile +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertNotNull + +class AssessmentResourceContractTest { + @Test + fun `save failure copy exists in every selectable locale`() { + listOf( + "values", + "values-nl", + "values-de", + "values-es", + "values-fr", + ).forEach { directory -> + val path = "src/commonMain/composeResources/$directory/strings.xml" + val source = readProjectFile(path) + assertNotNull(source, "Could not read $path") + assertContains(source, """name="assessment_save_failed"""", path) + } + } +} +``` + +In `AssessmentEngineTest.kt`, replace the old per-cable clamp test: + +```kotlin +@Test +fun `estimateOneRepMax clamps total result at dual cable hardware maximum`() { + val result = engine.estimateOneRepMax( + listOf( + LoadVelocityPoint(100f, 1.0f), + LoadVelocityPoint(105f, 0.99f), + ), + ) + + assertNotNull(result) + assertEquals(220f, result.estimatedOneRepMaxKg, 0.1f) +} +``` + +- [ ] **Step 4: Run the focused tests and confirm the red state** + +Run: + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest --tests "*AssessmentViewModelProfileScopeTest*" --tests "*AssessmentProfileOwnershipTest*" --tests "*NavigationRoutesTest*" --tests "*AssessmentResourceContractTest*" --tests "*AssessmentEngineTest*" --console=plain +``` + +Expected: FAIL because the explicit-ID/event APIs, profile-bearing route factories/callsites, destination-state resolver, localized key, safe profile-independent starting load, and 220 kg total clamp do not exist yet. + +- [ ] **Step 5: Remove assessment profile defaults and enforce total-kilogram engine semantics** + +In `AssessmentRepository.kt`, change the existing entity property from `val profileId: String = "default"` to `val profileId: String`. In both `saveAssessment` and `saveAssessmentSession`, change the existing final parameter from `profileId: String = "default"` to `profileId: String`: + +```kotlin +profileId: String, +``` + +Keep `userOverrideKg` optional, but document `estimatedOneRepMaxKg` and `userOverrideKg` as total kilograms and `weightPerCableKg` as per-cable kilograms. Every current caller is in this Task's inventory: the two `SqlDelightAssessmentRepositoryTest` calls and `AssessmentViewModel` for `saveAssessmentSession`; the new fake/repository isolation tests for `saveAssessment`; the two `NavGraph` calls for `AssessmentWizardScreen`; and `AssessmentWizardScreen` for `acceptResult`. Do not retain a compatibility overload. + +In `AssessmentEngine.kt`, make its existing total-valued regression agree with the wizard and persistence contract: + +```kotlin +val estimatedLoad = ((config.oneRmVelocityMs.toDouble() - intercept) / slope) + .coerceAtLeast(1.0) + .coerceAtMost((Constants.MAX_WEIGHT_PER_CABLE_KG * 2f).toDouble()) +``` + +In `AssessmentViewModel.startAssessmentInternal`, remove the `exercise.oneRepMaxKg` branch entirely. That column is global, so using it while an A-owned value is visible to profile B leaks profile state and can prescribe the wrong starting load. Until Task 2's profile-scoped resolver exists, seed every assessment conservatively in total-kilogram units: + +```kotlin +private fun startAssessmentInternal() { + assessmentStartTimeMs = currentTimeMillis() + if (selectedExercise == null) return + + _currentStep.value = AssessmentStep.ProgressiveLoading( + currentSetNumber = 1, + suggestedWeightKg = SAFE_STARTING_LOAD_TOTAL_KG, + recordedSets = emptyList(), + latestVelocity = null, + shouldStop = false, + ) +} + +private companion object { + const val SAFE_STARTING_LOAD_TOTAL_KG = 20f +} +``` + +Also remove the `exercise.oneRepMaxKg` badge from `AssessmentWizardScreen`'s exercise-picker row. Do not substitute `Exercise.oneRepMaxKg` elsewhere in the assessment flow. Task 2 may replace the conservative seed/display only after its resolver can accept the immutable route `profileId` and return a profile-scoped value. + +Add `AssessmentUiEvent` at top level beside `AssessmentStep`, then add the two flow properties inside `AssessmentViewModel`: + +```kotlin +sealed interface AssessmentUiEvent { + data object SaveFailed : AssessmentUiEvent +} + +private val _events = MutableSharedFlow(extraBufferCapacity = 1) +val events: SharedFlow = _events.asSharedFlow() +``` + +Import `kotlin.coroutines.cancellation.CancellationException`, `kotlinx.coroutines.flow.MutableSharedFlow`, `SharedFlow`, and `asSharedFlow`. Then replace `acceptResult`: + +```kotlin +fun acceptResult(profileId: String, overrideKg: Float? = null) { + require(profileId.isNotBlank()) { "Assessment profileId must not be blank" } + val exercise = selectedExercise ?: return + val current = _currentStep.value + if (current !is AssessmentStep.Results) return + + _currentStep.value = AssessmentStep.Saving + viewModelScope.launch { + try { + val finalOneRm = overrideKg?.takeIf { it > 0f } ?: current.estimatedOneRepMaxKg + val lvDataJson = Json.encodeToString( + current.loadVelocityPoints.map { + mapOf( + "loadKg" to it.loadKg.toString(), + "velocityMs" to it.meanVelocityMs.toString(), + ) + }, + ) + val totalReps = current.loadVelocityPoints.size * 3 + val durationMs = currentTimeMillis() - assessmentStartTimeMs + val avgWeight = current.loadVelocityPoints.map { it.loadKg }.average().toFloat() + + assessmentRepository.saveAssessmentSession( + exerciseId = exercise.id ?: exercise.name, + exerciseName = exercise.displayName, + estimatedOneRepMaxKg = current.estimatedOneRepMaxKg, + loadVelocityDataJson = lvDataJson, + userOverrideKg = overrideKg?.takeIf { it > 0f }, + totalReps = totalReps, + durationMs = durationMs, + weightPerCableKg = avgWeight / 2f, + profileId = profileId, + ) + + _currentStep.value = AssessmentStep.Complete( + finalOneRepMaxKg = finalOneRm, + exerciseName = exercise.displayName, + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Logger.e(e) { "Failed to save assessment" } + _currentStep.value = current + _events.emit(AssessmentUiEvent.SaveFailed) + } + } +} +``` + +The profile ID is validated synchronously before `Saving`, so blank ownership never launches a write. Estimated/override values forwarded to the repository and shown in `Complete` remain total kilograms; only `weightPerCableKg = avgWeight / 2f` crosses to per-cable storage. The safe first load is exactly 20 kg total; do not feed it through `suggestNextWeight`, and do not read the global exercise 1RM. + +- [ ] **Step 6: Put immutable Ready ownership in every assessment route and wire failure feedback** + +Change the screen signature and Results binding: + +```kotlin +@Composable +fun AssessmentWizardScreen( + viewModel: AssessmentViewModel, + profileId: String, + exerciseId: String? = null, + themeMode: ThemeMode, + onNavigateBack: () -> Unit, + metricsFlow: StateFlow? = null, +) { + require(profileId.isNotBlank()) + val currentStep by viewModel.currentStep.collectAsState() + val exercises by viewModel.exercises.collectAsState() + val snackbarHostState = remember { SnackbarHostState() } + val saveFailureMessage = stringResource(Res.string.assessment_save_failed) + LaunchedEffect(viewModel, saveFailureMessage) { + viewModel.events.collect { event -> + when (event) { + AssessmentUiEvent.SaveFailed -> + snackbarHostState.showSnackbar(saveFailureMessage) + } + } + } +} +``` + +Inside the function's existing exhaustive `when (val step = currentStep)`, replace its Results branch with this compiling branch; no other branch changes: + +```kotlin +is AssessmentStep.Results -> ResultsContent( + step = step, + onAccept = { overrideKg -> + viewModel.acceptResult( + profileId = profileId, + overrideKg = overrideKg, + ) + }, + onDiscard = viewModel::reset, +) +``` + +Import `AssessmentUiEvent` beside the existing `AssessmentStep`/`AssessmentViewModel` imports. Add `SnackbarHost(snackbarHostState, Modifier.align(Alignment.BottomCenter))` as the final child of the screen's existing outer `Box` so one event produces one visible message without replacing authoritative `Results` state. + +In `NavigationRoutes.kt`, replace both profile-less assessment routes. Require and encode every path segment so no caller can create an unowned destination: + +```kotlin +object StrengthAssessment : NavigationRoutes( + "strength_assessment/{profileId}/{exerciseId}", +) { + fun createRoute(profileId: String, exerciseId: String): String { + require(profileId.isNotBlank()) { "Assessment profileId must not be blank" } + require(exerciseId.isNotBlank()) { "Assessment exerciseId must not be blank" } + return "strength_assessment/${profileId.encodeRouteSegment()}/${exerciseId.encodeRouteSegment()}" + } +} + +object StrengthAssessmentPicker : NavigationRoutes( + "strength_assessment_picker/{profileId}", +) { + fun createRoute(profileId: String): String { + require(profileId.isNotBlank()) { "Assessment profileId must not be blank" } + return "strength_assessment_picker/${profileId.encodeRouteSegment()}" + } +} +``` + +In `AnalyticsScreen.kt`, change both the public screen and internal content signatures to accept `assessmentProfileId: String?` and `onNavigateToStrengthAssessment: (String) -> Unit`. Forward both values through the existing call. Change the assessment card to: + +```kotlin +ExpressiveCard( + onClick = { + assessmentProfileId?.let(onNavigateToStrengthAssessment) + }, + enabled = assessmentProfileId != null, + modifier = Modifier.fillMaxWidth(), +) { + // existing card content unchanged +} +``` + +In `ExerciseDetailScreen.kt`, replace its `navController: NavController` parameter with `assessmentProfileId: String?` and `onNavigateToStrengthAssessment: (String) -> Unit`; this is the file's only navigation use, so delete the `NavController` and `NavigationRoutes` imports. Replace the button callback/guard with: + +```kotlin +onClick = { + assessmentProfileId?.let(onNavigateToStrengthAssessment) +}, +enabled = isConnected && assessmentProfileId != null, +``` + +In `NavGraph`, inject `UserProfileRepository` once at the start of `NavGraph`, collect `activeProfileContext`, and derive a nullable Ready ID: + +```kotlin +val profileRepository: UserProfileRepository = koinInject() +val activeProfileContext by profileRepository.activeProfileContext.collectAsState() +val assessmentProfileId = + (activeProfileContext as? ActiveProfileContext.Ready)?.profile?.id +``` + +Pass `assessmentProfileId` to Analytics and Exercise Detail. Their callbacks create routes only from the non-null ID delivered by the screen: + +```kotlin +// Analytics +onNavigateToStrengthAssessment = { profileId -> + navController.navigate( + NavigationRoutes.StrengthAssessmentPicker.createRoute(profileId), + ) +} + +// Exercise Detail; exerciseId is the already validated immutable route argument +onNavigateToStrengthAssessment = { profileId -> + navController.navigate( + NavigationRoutes.StrengthAssessment.createRoute(profileId, exerciseId), + ) +} +``` + +Add this pure destination resolver outside `NavGraph`: + +```kotlin +internal sealed interface AssessmentProfileDestinationState { + data class Bound(val profileId: String) : AssessmentProfileDestinationState + data object Invalidated : AssessmentProfileDestinationState +} + +internal fun resolveAssessmentProfileDestination( + routeProfileId: String, + context: ActiveProfileContext, +): AssessmentProfileDestinationState = + if ( + routeProfileId.isNotBlank() && + context is ActiveProfileContext.Ready && + context.profile.id == routeProfileId + ) { + AssessmentProfileDestinationState.Bound(routeProfileId) + } else { + AssessmentProfileDestinationState.Invalidated + } +``` + +Add one helper used by both assessment destinations: + +```kotlin +@Composable +private fun AssessmentDestination( + profileId: String, + exerciseId: String?, + activeContext: ActiveProfileContext, + themeMode: ThemeMode, + metricsFlow: StateFlow, + onNavigateBack: () -> Unit, +) { + val destinationState = resolveAssessmentProfileDestination( + routeProfileId = profileId, + context = activeContext, + ) + + LaunchedEffect(destinationState) { + if (destinationState == AssessmentProfileDestinationState.Invalidated) { + onNavigateBack() + } + } + + when (destinationState) { + is AssessmentProfileDestinationState.Bound -> { + val assessmentViewModel: AssessmentViewModel = koinViewModel() + AssessmentWizardScreen( + viewModel = assessmentViewModel, + profileId = destinationState.profileId, + exerciseId = exerciseId, + themeMode = themeMode, + onNavigateBack = onNavigateBack, + metricsFlow = metricsFlow, + ) + } + AssessmentProfileDestinationState.Invalidated -> Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + LoadingIndicator(LoadingIndicatorSize.Large) + } + } +} +``` + +Import `ActiveProfileContext`, `UserProfileRepository`, `WorkoutMetric`, `StateFlow`, and `org.koin.compose.viewmodel.koinViewModel`. Route-scoped `koinViewModel` is required: popping an invalidated entry clears the ViewModel and cancels its save job. The remaining `LaunchedEffect` only pops an already-invalid immutable route; it never captures ownership. + +Delete both existing `val assessmentViewModel: AssessmentViewModel = koinInject()` lines. Give the picker destination a required `profileId` navigation argument, read it from `backStackEntry`, and pop/return on a null or blank value before calling: + +```kotlin +AssessmentDestination( + profileId = profileId, + exerciseId = null, + activeContext = activeProfileContext, + themeMode = themeMode, + metricsFlow = viewModel.currentMetric, + onNavigateBack = { navController.popBackStack() }, +) +``` + +Give the preselected destination required `profileId` and `exerciseId` arguments, validate both, and call the same helper with `profileId = profileId`, `exerciseId = exerciseId`, and `activeContext = activeProfileContext`. The only active-profile read allowed is the equality check that invalidates a stale route; ownership always comes from `backStackEntry.arguments`. + +Add the localized failure key: + +```xml + +Couldn’t save the assessment. Try again. + +De krachtmeting kon niet worden opgeslagen. Probeer het opnieuw. + +Die Kraftmessung konnte nicht gespeichert werden. Versuche es erneut. + +No se pudo guardar la evaluación. Inténtalo de nuevo. + +Impossible d’enregistrer l’évaluation. Réessayez. +``` + +Each line goes only in its named locale file. The route behavior is: + +- entry points are disabled unless context is `Ready`, and synchronously encode that Ready profile ID into the route; +- `Ready(A)` plus an A route renders only an A-owned wizard; +- `Switching`, `Ready(B)`, or a malformed/restored route removes the wizard immediately and pops; never render A state against B; +- cancellation from that pop reaches `acceptResult` and the repository unchanged. + +- [ ] **Step 7: Make persistence atomic under error/cancellation and prove profile/unit isolation** + +In `VitruvianDatabase.sq`, add a compare-and-set restore beside `updateOneRepMax`: + +```sql +restoreOneRepMaxIfCurrent: +UPDATE Exercise +SET one_rep_max_kg = :previousOneRepMaxKg +WHERE id = :exerciseId + AND one_rep_max_kg = :attemptedOneRepMaxKg; +``` + +An assessment may restore its snapshot only while the column still equals the exact per-cable value that assessment attempted. If any external writer has published a newer value, this statement affects zero rows and preserves it. + +In `SqlDelightAssessmentRepository`, add a defaulted dispatcher seam and one repository-wide mutex. The same mutex must cover both `saveAssessment` and `saveAssessmentSession`; otherwise concurrent inserts can race `lastInsertRowId()` even if session compensation is serialized: + +```kotlin +class SqlDelightAssessmentRepository( + db: VitruvianDatabase, + private val workoutRepository: WorkoutRepository, + private val exerciseRepository: ExerciseRepository, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) : AssessmentRepository { + private val queries = db.vitruvianDatabaseQueries + private val assessmentWriteMutex = Mutex() +``` + +Import `CoroutineDispatcher`, `NonCancellable`, `Mutex`, `withLock`, and `kotlin.coroutines.cancellation.CancellationException`. Validate profile ownership before dispatch/locking, then replace `saveAssessment` so insert plus ID lookup is one serialized identity operation: + +```kotlin +override suspend fun saveAssessment( + exerciseId: String, + estimatedOneRepMaxKg: Float, + loadVelocityDataJson: String, + sessionId: String?, + userOverrideKg: Float?, + profileId: String, +): Long { + require(profileId.isNotBlank()) { "Assessment profileId must not be blank" } + return withContext(ioDispatcher) { + assessmentWriteMutex.withLock { + queries.insertAssessmentResult( + exerciseId = exerciseId, + estimatedOneRepMaxKg = estimatedOneRepMaxKg.toDouble(), + loadVelocityData = loadVelocityDataJson, + assessmentSessionId = sessionId, + userOverrideKg = userOverrideKg?.toDouble(), + createdAt = currentTimeMillis(), + profile_id = profileId, + ) + queries.lastInsertRowId().executeAsOne() + } + } +} +``` + +Replace `saveAssessmentSession` completely: + +```kotlin +override suspend fun saveAssessmentSession( + exerciseId: String, + exerciseName: String, + estimatedOneRepMaxKg: Float, + loadVelocityDataJson: String, + userOverrideKg: Float?, + totalReps: Int, + durationMs: Long, + weightPerCableKg: Float, + profileId: String, +): String { + require(profileId.isNotBlank()) { "Assessment profileId must not be blank" } + return withContext(ioDispatcher) { + assessmentWriteMutex.withLock { + val attemptedOneRepMaxPerCableKg = + (userOverrideKg ?: estimatedOneRepMaxKg) / 2f + val sessionId = generateUUID() + val session = WorkoutSession( + id = sessionId, + timestamp = currentTimeMillis(), + mode = "OldSchool", + reps = totalReps, + weightPerCableKg = weightPerCableKg, + duration = durationMs, + totalReps = totalReps, + exerciseId = exerciseId, + exerciseName = exerciseName, + routineName = ASSESSMENT_ROUTINE_NAME, + profileId = profileId, + ) + + var insertedResultId: Long? = null + var previousOneRepMaxPerCableKg: Float? = null + var exerciseWriteAttempted = false + try { + workoutRepository.saveSession(session) + queries.insertAssessmentResult( + exerciseId = exerciseId, + estimatedOneRepMaxKg = estimatedOneRepMaxKg.toDouble(), + loadVelocityData = loadVelocityDataJson, + assessmentSessionId = sessionId, + userOverrideKg = userOverrideKg?.toDouble(), + createdAt = currentTimeMillis(), + profile_id = profileId, + ) + insertedResultId = queries.lastInsertRowId().executeAsOne() + + previousOneRepMaxPerCableKg = + exerciseRepository.getExerciseById(exerciseId)?.oneRepMaxKg + exerciseWriteAttempted = true + exerciseRepository.updateOneRepMax( + exerciseId, + attemptedOneRepMaxPerCableKg, + ) + } catch (failure: Throwable) { + withContext(NonCancellable) { + if (exerciseWriteAttempted) { + runCatching { + queries.restoreOneRepMaxIfCurrent( + previousOneRepMaxKg = + previousOneRepMaxPerCableKg?.toDouble(), + exerciseId = exerciseId, + attemptedOneRepMaxKg = + attemptedOneRepMaxPerCableKg.toDouble(), + ) + } + } + insertedResultId?.let { id -> + runCatching { queries.deleteAssessmentResult(id) } + } + runCatching { workoutRepository.deleteSession(sessionId) } + } + if (failure !is CancellationException) { + Logger.w(failure) { + "Assessment save failed; compensated owned writes" + } + } + throw failure + } + + sessionId + } + } +} +``` + +The mutex covers the full logical save, but the prior 1RM snapshot is intentionally read immediately before the exercise write, after the session/result writes, so it cannot become stale merely because those earlier writes took time. The `try` begins before `saveSession`, so cancellation at any write boundary cleans up owned rows. `exerciseWriteAttempted` stays false for session/result/pre-snapshot failures; once true, restoration is still conditional on the SQL compare-and-set. Cleanup is non-cancellable, uses the local SQLDelight query rather than the injected/failing `ExerciseRepository`, and propagates the original failure type and stable message (coroutine stack-trace recovery may copy the exception object). + +Replace the two existing save-session tests with the complete unit/profile contract: + +```kotlin +@Test +fun `saveAssessmentSession keeps estimate total and stores session and exercise per cable`() = runTest { + val sessionId = repository.saveAssessmentSession( + exerciseId = "bench-press", + exerciseName = "Bench Press", + estimatedOneRepMaxKg = 100f, + loadVelocityDataJson = "[]", + userOverrideKg = null, + totalReps = 9, + durationMs = 60_000L, + weightPerCableKg = 30f, + profileId = "athlete-a", + ) + + val session = workoutRepository.getSession(sessionId) + val assessment = repository.getLatestAssessment("bench-press", "athlete-a") + assertEquals("athlete-a", session?.profileId) + assertEquals(30f, session?.weightPerCableKg) + assertEquals(100f, assessment?.estimatedOneRepMaxKg) + assertNull(assessment?.userOverrideKg) + assertEquals(sessionId, assessment?.assessmentSessionId) + assertEquals(50f, exerciseRepository.getExerciseById("bench-press")?.oneRepMaxKg) +} + +@Test +fun `saveAssessmentSession keeps override total and updates exercise per cable`() = runTest { + val sessionId = repository.saveAssessmentSession( + exerciseId = "bench-press", + exerciseName = "Bench Press", + estimatedOneRepMaxKg = 100f, + loadVelocityDataJson = "[]", + userOverrideKg = 120f, + totalReps = 9, + durationMs = 60_000L, + weightPerCableKg = 30f, + profileId = "athlete-a", + ) + + val assessment = repository.getLatestAssessment("bench-press", "athlete-a") + assertEquals(100f, assessment?.estimatedOneRepMaxKg) + assertEquals(120f, assessment?.userOverrideKg) + assertEquals(sessionId, assessment?.assessmentSessionId) + assertEquals(60f, exerciseRepository.getExerciseById("bench-press")?.oneRepMaxKg) +} +``` + +Then add explicit profile isolation: + +```kotlin +@Test +fun `latest assessment is isolated by explicit profile`() = runTest { + repository.saveAssessment( + exerciseId = "bench-press", + estimatedOneRepMaxKg = 100f, + loadVelocityDataJson = "[]", + sessionId = null, + userOverrideKg = null, + profileId = "athlete-a", + ) + repository.saveAssessment( + exerciseId = "bench-press", + estimatedOneRepMaxKg = 140f, + loadVelocityDataJson = "[]", + sessionId = null, + userOverrideKg = null, + profileId = "athlete-b", + ) + + assertEquals(100f, repository.getLatestAssessment("bench-press", "athlete-a")?.estimatedOneRepMaxKg) + assertEquals(140f, repository.getLatestAssessment("bench-press", "athlete-b")?.estimatedOneRepMaxKg) +} +``` + +Add an insert-identity regression proving every concurrent `saveAssessment` result belongs to the row/profile it inserted. Use unique profiles so each row can be read back unambiguously: + +```kotlin +@Test +fun `direct and session inserts share one mutex and direct saves return their own row IDs`() = runTest { + val directSaves = (0 until 16).map { index -> + async(Dispatchers.Default) { + val profileId = "direct-$index" + val id = repository.saveAssessment( + exerciseId = "bench-press", + estimatedOneRepMaxKg = 100f + index, + loadVelocityDataJson = "[]", + sessionId = null, + userOverrideKg = null, + profileId = profileId, + ) + profileId to id + } + } + val sessionSaves = (0 until 16).map { index -> + async(Dispatchers.Default) { + saveSession(repository, profileId = "session-$index") + } + } + + sessionSaves.awaitAll() + val saves = directSaves.awaitAll() + + assertEquals(16, saves.map { it.second }.distinct().size) + saves.forEach { (profileId, returnedId) -> + assertEquals( + returnedId, + repository.getLatestAssessment("bench-press", profileId)?.id, + ) + } +} +``` + +Import `kotlinx.coroutines.Dispatchers`, `async`, and `awaitAll`. This test protects the shared mutex around `insertAssessmentResult` plus `lastInsertRowId`; a mutex only in `saveAssessmentSession` is insufficient. + +Prove the repository rejects blank ownership before any write: + +```kotlin +@Test +fun `blank profile IDs are rejected before assessment writes`() = runTest { + assertFailsWith { + repository.saveAssessment( + exerciseId = "bench-press", + estimatedOneRepMaxKg = 100f, + loadVelocityDataJson = "[]", + sessionId = null, + userOverrideKg = null, + profileId = " ", + ) + } + assertFailsWith { + repository.saveAssessmentSession( + exerciseId = "bench-press", + exerciseName = "Bench Press", + estimatedOneRepMaxKg = 100f, + loadVelocityDataJson = "[]", + userOverrideKg = null, + totalReps = 9, + durationMs = 60_000L, + weightPerCableKg = 30f, + profileId = " ", + ) + } + + assertNull(repository.getLatestAssessment("bench-press", " ")) + assertEquals(emptyList(), workoutRepository.getAllSessions(" ").first()) +} +``` + +Add deterministic compensation/race coverage. Use this local call helper to keep every save's fixtures identical: + +```kotlin +private suspend fun saveSession( + target: AssessmentRepository, + profileId: String = "athlete-a", + overrideTotalKg: Float? = null, +): String = target.saveAssessmentSession( + exerciseId = "bench-press", + exerciseName = "Bench Press", + estimatedOneRepMaxKg = 100f, + loadVelocityDataJson = "[]", + userOverrideKg = overrideTotalKg, + totalReps = 9, + durationMs = 60_000L, + weightPerCableKg = 30f, + profileId = profileId, +) +``` + +First prove an ordinary post-write failure restores only its own attempted value: + +```kotlin +@Test +fun `ordinary post-write failure removes rows and restores prior per-cable 1RM`() = runTest { + exerciseRepository.updateOneRepMax("bench-press", 40f) + val failure = IllegalStateException("test failure") + val failingRepository = repositoryFailingAfterExerciseUpdate(failure) + + val thrown = assertFailsWith { + saveSession(failingRepository) + } + assertEquals(failure.message, thrown.message) + + assertEquals(emptyList(), workoutRepository.getAllSessions("athlete-a").first()) + assertNull(failingRepository.getLatestAssessment("bench-press", "athlete-a")) + assertEquals(40f, exerciseRepository.getExerciseById("bench-press")?.oneRepMaxKg) +} +``` + +Then prove both halves of the guarded compare-and-set policy. A failure before the exercise write must not restore the stale snapshot; a failure after the attempted write must not overwrite a newer external value: + +```kotlin +@Test +fun `pre-write failure preserves a concurrent exercise 1RM update`() = runTest { + exerciseRepository.updateOneRepMax("bench-press", 40f) + val failure = IllegalStateException("session write failed") + val failingWorkouts = object : WorkoutRepository by workoutRepository { + override suspend fun saveSession(session: WorkoutSession) { + exerciseRepository.updateOneRepMax("bench-press", 55f) + throw failure + } + } + val target = SqlDelightAssessmentRepository( + database, + failingWorkouts, + exerciseRepository, + ) + + val thrown = assertFailsWith { + saveSession(target) + } + assertEquals(failure.message, thrown.message) + + assertNull(target.getLatestAssessment("bench-press", "athlete-a")) + assertEquals(55f, exerciseRepository.getExerciseById("bench-press")?.oneRepMaxKg) +} + +@Test +fun `CAS compensation preserves newer 1RM after an attempted exercise write`() = runTest { + exerciseRepository.updateOneRepMax("bench-press", 40f) + val failure = IllegalStateException("post-write failure") + val target = repositoryFailingAfterExerciseUpdate(failure) { + exerciseRepository.updateOneRepMax("bench-press", 55f) + } + + val thrown = assertFailsWith { + saveSession(target) + } + assertEquals(failure.message, thrown.message) + + assertEquals(emptyList(), workoutRepository.getAllSessions("athlete-a").first()) + assertNull(target.getLatestAssessment("bench-press", "athlete-a")) + assertEquals(55f, exerciseRepository.getExerciseById("bench-press")?.oneRepMaxKg) +} +``` + +Cancellation must be a real structured-concurrency cancellation at a paused post-write seam, not an injected function that merely throws a synthetic `CancellationException`: + +```kotlin +@Test +fun `real child cancellation runs NonCancellable compensation and escapes unchanged`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + exerciseRepository.updateOneRepMax("bench-press", 40f) + val exerciseWriteApplied = CompletableDeferred() + val pausingExercises = object : ExerciseRepository by exerciseRepository { + override suspend fun updateOneRepMax(exerciseId: String, oneRepMaxKg: Float?) { + exerciseRepository.updateOneRepMax(exerciseId, oneRepMaxKg) + exerciseWriteApplied.complete(Unit) + awaitCancellation() + } + } + val target = SqlDelightAssessmentRepository( + database, + workoutRepository, + pausingExercises, + ioDispatcher = dispatcher, + ) + val child = async { saveSession(target, overrideTotalKg = 120f) } + runCurrent() + exerciseWriteApplied.await() + + val original = CancellationException("test cancellation") + child.cancel(original) + runCurrent() + val thrown = assertFailsWith { child.await() } + assertEquals(original.message, thrown.message) + + assertEquals(emptyList(), workoutRepository.getAllSessions("athlete-a").first()) + assertNull(target.getLatestAssessment("bench-press", "athlete-a")) + assertEquals(40f, exerciseRepository.getExerciseById("bench-press")?.oneRepMaxKg) +} +``` + +Finally, deterministically pause a failing save after its exercise write, start a successful save for the same exercise, and prove the repository mutex prevents the second writer from entering until compensation completes: + +```kotlin +@Test +fun `failing and successful saves for one exercise serialize snapshot write and compensation`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + exerciseRepository.updateOneRepMax("bench-press", 40f) + val firstWriteApplied = CompletableDeferred() + val releaseFirstFailure = CompletableDeferred() + val firstFailure = IllegalStateException("first save failed") + var updateCalls = 0 + val controlledExercises = object : ExerciseRepository by exerciseRepository { + override suspend fun updateOneRepMax(exerciseId: String, oneRepMaxKg: Float?) { + updateCalls += 1 + exerciseRepository.updateOneRepMax(exerciseId, oneRepMaxKg) + if (updateCalls == 1) { + firstWriteApplied.complete(Unit) + releaseFirstFailure.await() + throw firstFailure + } + } + } + val target = SqlDelightAssessmentRepository( + database, + workoutRepository, + controlledExercises, + ioDispatcher = dispatcher, + ) + + val failing = async { + runCatching { saveSession(target, profileId = "athlete-a") } + } + runCurrent() + firstWriteApplied.await() + val succeeding = async { + saveSession(target, profileId = "athlete-b", overrideTotalKg = 120f) + } + runCurrent() + assertEquals(1, updateCalls, "second save must still be waiting on the mutex") + + releaseFirstFailure.complete(Unit) + runCurrent() + val firstThrown = assertIs( + failing.await().exceptionOrNull(), + ) + assertEquals(firstFailure.message, firstThrown.message) + val successfulSessionId = succeeding.await() + + assertEquals(emptyList(), workoutRepository.getAllSessions("athlete-a").first()) + assertEquals( + successfulSessionId, + workoutRepository.getAllSessions("athlete-b").first().single().id, + ) + assertNull(target.getLatestAssessment("bench-press", "athlete-a")) + assertEquals( + 120f, + target.getLatestAssessment("bench-press", "athlete-b")?.userOverrideKg, + ) + assertEquals(60f, exerciseRepository.getExerciseById("bench-press")?.oneRepMaxKg) +} + +private fun repositoryFailingAfterExerciseUpdate( + failure: Throwable, + afterAttempt: suspend () -> Unit = {}, +): SqlDelightAssessmentRepository { + val failingExercises = object : ExerciseRepository by exerciseRepository { + override suspend fun updateOneRepMax(exerciseId: String, oneRepMaxKg: Float?) { + exerciseRepository.updateOneRepMax(exerciseId, oneRepMaxKg) + afterAttempt() + throw failure + } + } + return SqlDelightAssessmentRepository( + database, + workoutRepository, + failingExercises, + ) +} +``` + +Import `CompletableDeferred`, `Dispatchers`, `async`, `awaitAll`, `awaitCancellation`, `StandardTestDispatcher`, `runCurrent`, `CancellationException`, `assertFailsWith`, `assertIs`, `assertNull`, and `kotlinx.coroutines.flow.first`. Assert propagated exception type plus stable message, not referential identity: coroutine stack-trace recovery may copy an exception. The pre-write test proves the guard; the newer-value test proves SQL CAS; the real child cancellation proves `NonCancellable` cleanup and cancellation propagation; the controlled dispatcher test proves snapshot/write/compensation serialization without timing sleeps, and its failing child returns `runCatching` so it cannot cancel the parent test. + +- [ ] **Step 8: Run focused tests, compile every caller, and scan removed defaults** + +Run: + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest --tests "*AssessmentViewModelProfileScopeTest*" --tests "*AssessmentProfileOwnershipTest*" --tests "*NavigationRoutesTest*" --tests "*AssessmentResourceContractTest*" --tests "*AssessmentEngineTest*" --tests "*SqlDelightAssessmentRepositoryTest*" --rerun --console=plain +``` + +Expected: BUILD SUCCESSFUL. Do not infer that every requested filter matched merely from the task exit code. Prove a positive JUnit XML count for each exact class: + +```powershell +$resultRoot = 'shared/build/test-results/testAndroidHostTest' +$documents = @( + Get-ChildItem $resultRoot -Filter 'TEST-*.xml' | + ForEach-Object { [xml](Get-Content $_.FullName -Raw) } +) +$expectedClasses = @( + 'com.devil.phoenixproject.presentation.viewmodel.AssessmentViewModelProfileScopeTest', + 'com.devil.phoenixproject.presentation.navigation.AssessmentProfileOwnershipTest', + 'com.devil.phoenixproject.presentation.navigation.NavigationRoutesTest', + 'com.devil.phoenixproject.presentation.screen.AssessmentResourceContractTest', + 'com.devil.phoenixproject.domain.assessment.AssessmentEngineTest', + 'com.devil.phoenixproject.data.repository.SqlDelightAssessmentRepositoryTest' +) +foreach ($className in $expectedClasses) { + $count = 0 + foreach ($document in $documents) { + $count += @( + $document.SelectNodes("//testcase[@classname='$className']") + ).Count + } + if ($count -lt 1) { + throw "No executed test cases found for $className" + } + Write-Output "$className : $count" +} +``` + +Expected: every exact class prints a count greater than zero. The preceding Gradle success establishes zero failures/errors; this XML gate establishes that no filter silently matched nothing. + +Compile common production/test call sites for both configured platform families: + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:compileAndroidMain :shared:compileAndroidHostTest :shared:compileKotlinIosArm64 :shared:compileTestKotlinIosArm64 --console=plain +``` + +Expected: BUILD SUCCESSFUL. The configured iOS tasks are `compileKotlinIosArm64` and `compileTestKotlinIosArm64`; there is no `compileKotlinIosSimulatorArm64` task in this repository. + +Run the exact ownership/default scan: + +```powershell +$assessmentApi = 'shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/AssessmentRepository.kt' +$defaults = rg -n 'profileId: String = "default"' $assessmentApi +if ($LASTEXITCODE -eq 0) { + $defaults + throw 'Assessment profile defaults remain' +} +rg -n 'AssessmentResultEntity\(' shared/src --glob '*.kt' +rg -n '\b(saveAssessment|saveAssessmentSession|getAssessmentsByExercise|getLatestAssessment|acceptResult|AssessmentWizardScreen)\s*\(' shared/src --glob '*.kt' +$unsafeStartingLoad = rg -n 'exercise\.oneRepMaxKg' shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/AssessmentViewModel.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/AssessmentWizardScreen.kt +if ($LASTEXITCODE -eq 0) { + $unsafeStartingLoad + throw 'Assessment still reads the unscoped global exercise 1RM' +} +$profilelessRoutes = rg -n --pcre2 '\bnavigate\s*\(\s*(?:route\s*=\s*)?NavigationRoutes\.StrengthAssessment(?:Picker)?\.route\s*[,)]' shared/src/commonMain/kotlin shared/src/androidMain/kotlin shared/src/iosMain/kotlin --glob '*.kt' +if ($LASTEXITCODE -eq 0) { + $profilelessRoutes + throw 'A profile-less assessment navigation call remains' +} +rg -n 'StrengthAssessment(Picker)?(\.createRoute)?' shared/src --glob '*.kt' +``` + +Expected: all guards exit normally. No assessment API/entity profile default, global-exercise 1RM read, or profile-less route call remains. The final route inventory includes the two route definitions, the Ready-gated `NavGraph` factories, and focused tests only; Analytics and Exercise Detail pass explicit Ready IDs through callbacks rather than navigating directly. + +- [ ] **Step 9: Commit the assessment ownership slice** + +```powershell +git add shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/VitruvianDatabase.sq shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/AssessmentRepository.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/SqlDelightAssessmentRepository.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/assessment/AssessmentEngine.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/AssessmentViewModel.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/AssessmentWizardScreen.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/AnalyticsScreen.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailScreen.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/navigation/NavigationRoutes.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/navigation/NavGraph.kt shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeAssessmentRepository.kt shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/viewmodel/AssessmentViewModelProfileScopeTest.kt shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/navigation/AssessmentProfileOwnershipTest.kt shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/navigation/NavigationRoutesTest.kt shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/AssessmentResourceContractTest.kt shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightAssessmentRepositoryTest.kt shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/assessment/AssessmentEngineTest.kt shared/src/commonMain/composeResources/values/strings.xml shared/src/commonMain/composeResources/values-nl/strings.xml shared/src/commonMain/composeResources/values-de/strings.xml shared/src/commonMain/composeResources/values-es/strings.xml shared/src/commonMain/composeResources/values-fr/strings.xml +git commit -m "fix: scope strength assessments to active profile" +``` + +--- + +### Task 2: Add Bounded Exercise History Reads and the Shared Current-1RM Resolver + +**Files:** +- Create: `shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCase.kt` +- Create: `shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCaseTest.kt` +- Create: `shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailOneRepMaxLoadTest.kt` +- Modify: `shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/VitruvianDatabase.sq` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/WorkoutRepository.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/SqlDelightWorkoutRepository.kt` +- Modify: `shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeWorkoutRepository.kt` +- Modify: `shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeVelocityOneRepMaxRepository.kt` +- Modify: `shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeAssessmentRepository.kt` +- Modify: `shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightWorkoutRepositoryTest.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/di/DomainModule.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailScreen.kt` + +**Interfaces:** +- Consumes: Task 1's explicit profile-safe Assessment APIs and Ready-gated `ExerciseDetailScreen.assessmentProfileId`; `VelocityOneRepMaxRepository.getLatestPassing`; `OneRepMaxCalculator.estimate`. +- Produces: `MAX_RECENT_EXERCISE_SESSIONS = 5`, strict `WorkoutRepository.getMostRecentCompletedExerciseId`, bounded `WorkoutRepository.getRecentCompletedSessionsForExercise`, `WorkoutSession.estimatedOneRepMaxPerCableOrNull()`, and `ResolveCurrentOneRepMaxUseCase.invoke(exerciseId, profileId): CurrentOneRepMax?` for Tasks 5 and 6. +- Error contract: repository validation failures and source read failures propagate from the resolver, because swallowing a failed higher-priority read could incorrectly select a lower source. Exercise Detail catches only ordinary failures into its own 1RM-card state; it rethrows cancellation and leaves charts/history usable. + +- [ ] **Step 1: Write failing SQLDelight repository tests for eligibility and limit** + +Add three tests using the test class's existing database/repository setup. The first two deliberately create equal timestamps so the secondary ID ordering is observable: + +```kotlin +@Test +fun `recent completed sessions are profile exercise scoped newest first and limited`() = runTest { + repository.saveSession(workoutSession("old", "a", "bench", 10L, workingReps = 5)) + repository.saveSession(workoutSession("total-only", "a", "bench", 20L, workingReps = 0, totalReps = 4)) + repository.saveSession(workoutSession("a-tie", "a", "bench", 30L, workingReps = 3)) + repository.saveSession(workoutSession("z-tie", "a", "bench", 30L, workingReps = 3)) + repository.saveSession(workoutSession("wrong-exercise", "a", "squat", 50L, workingReps = 5)) + repository.saveSession(workoutSession("wrong-profile", "b", "bench", 60L, workingReps = 5)) + repository.saveSession(workoutSession("zero", "a", "bench", 70L, workingReps = 0, totalReps = 0)) + repository.saveSession(workoutSession("deleted", "a", "bench", 80L, workingReps = 5)) + database.vitruvianDatabaseQueries.softDeleteSession( + id = "deleted", + deletedAt = 81L, + updatedAt = 81L, + ) + + val result = repository.getRecentCompletedSessionsForExercise( + exerciseId = "bench", + profileId = "a", + limit = 3, + ) + + assertEquals(listOf("z-tie", "a-tie", "total-only"), result.map { it.id }) +} + +@Test +fun `most recent completed exercise uses deterministic live eligible row`() = runTest { + repository.saveSession(workoutSession("a-tie", "a", "bench", 30L, workingReps = 5)) + repository.saveSession(workoutSession("z-tie", "a", "squat", 30L, workingReps = 0, totalReps = 2)) + repository.saveSession(workoutSession("ghost", "a", "deadlift", 50L, workingReps = 0, totalReps = 0)) + repository.saveSession(workoutSession("blank-exercise", "a", " ", 55L, workingReps = 5)) + repository.saveSession(workoutSession("other-profile", "b", "row", 60L, workingReps = 5)) + repository.saveSession(workoutSession("deleted", "a", "press", 70L, workingReps = 5)) + database.vitruvianDatabaseQueries.softDeleteSession( + id = "deleted", + deletedAt = 71L, + updatedAt = 71L, + ) + + assertEquals("squat", repository.getMostRecentCompletedExerciseId("a")) +} + +@Test +fun `bounded reads reject blank ownership and limits outside one through five`() = runTest { + assertFailsWith { + repository.getRecentCompletedSessionsForExercise(" ", "a", 1) + } + assertFailsWith { + repository.getRecentCompletedSessionsForExercise("bench", " ", 1) + } + listOf(0, -1, MAX_RECENT_EXERCISE_SESSIONS + 1).forEach { invalidLimit -> + assertFailsWith { + repository.getRecentCompletedSessionsForExercise("bench", "a", invalidLimit) + } + } + assertFailsWith { + repository.getMostRecentCompletedExerciseId(" ") + } +} +``` + +Import `MAX_RECENT_EXERCISE_SESSIONS` and `kotlin.test.assertFailsWith`. The deleted rows must be created normally and tombstoned through the existing `softDeleteSession(id, deletedAt, updatedAt)` query so the SQL `deletedAt IS NULL` predicate, rather than test fixture omission, is exercised. + +Add this local helper using the current `WorkoutSession` constructor defaults: + +```kotlin +private fun workoutSession( + id: String, + profileId: String, + exerciseId: String, + timestamp: Long, + workingReps: Int, + totalReps: Int = workingReps, +): WorkoutSession = WorkoutSession( + id = id, + timestamp = timestamp, + mode = "OldSchool", + reps = totalReps, + weightPerCableKg = 40f, + duration = 1_000L, + totalReps = totalReps, + workingReps = workingReps, + exerciseId = exerciseId, + exerciseName = exerciseId, + profileId = profileId, +) +``` + +- [ ] **Step 2: Run the query tests and confirm the red state** + +Run: + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest --tests "*SqlDelightWorkoutRepositoryTest*" --console=plain +``` + +Expected: FAIL to compile because both repository methods are absent. + +- [ ] **Step 3: Add the bounded SQL queries and repository contract** + +Add to `VitruvianDatabase.sq` next to other WorkoutSession reads: + +```sql +selectRecentCompletedSessionsForExercise: +SELECT * FROM WorkoutSession +WHERE profile_id = :profileId + AND exerciseId = :exerciseId + AND deletedAt IS NULL + AND (workingReps > 0 OR totalReps > 0) +ORDER BY timestamp DESC, id DESC +LIMIT :limit; + +selectMostRecentCompletedExerciseId: +SELECT exerciseId FROM WorkoutSession +WHERE profile_id = :profileId + AND exerciseId IS NOT NULL + AND TRIM(exerciseId) != '' + AND deletedAt IS NULL + AND (workingReps > 0 OR totalReps > 0) +ORDER BY timestamp DESC, id DESC +LIMIT 1; +``` + +Add the shared cap and methods to `WorkoutRepository.kt`: + +```kotlin +const val MAX_RECENT_EXERCISE_SESSIONS = 5 + +suspend fun getRecentCompletedSessionsForExercise( + exerciseId: String, + profileId: String, + limit: Int = MAX_RECENT_EXERCISE_SESSIONS, +): List + +suspend fun getMostRecentCompletedExerciseId(profileId: String): String? +``` + +- [ ] **Step 4: Implement production and fake repository methods** + +Production: + +```kotlin +override suspend fun getRecentCompletedSessionsForExercise( + exerciseId: String, + profileId: String, + limit: Int, +): List { + require(exerciseId.isNotBlank()) { "exerciseId must not be blank" } + require(profileId.isNotBlank()) { "profileId must not be blank" } + require(limit in 1..MAX_RECENT_EXERCISE_SESSIONS) { + "limit must be in 1..$MAX_RECENT_EXERCISE_SESSIONS" + } + return withContext(Dispatchers.IO) { + queries.selectRecentCompletedSessionsForExercise( + profileId = profileId, + exerciseId = exerciseId, + limit = limit.toLong(), + mapper = ::mapToSession, + ).executeAsList() + } +} + +override suspend fun getMostRecentCompletedExerciseId(profileId: String): String? { + require(profileId.isNotBlank()) { "profileId must not be blank" } + return withContext(Dispatchers.IO) { + queries.selectMostRecentCompletedExerciseId(profileId).executeAsOneOrNull() + } +} +``` + +Import `MAX_RECENT_EXERCISE_SESSIONS`. Validate before dispatching so the production and fake contracts fail identically without touching storage. + +Fake: inside `FakeWorkoutRepository`, add the nested request type, read-failure controls, and request recording for resolver/error-bound tests, then mirror the same guards and deterministic ordering: + +```kotlin +data class RecentCompletedRequest( + val exerciseId: String, + val profileId: String, + val limit: Int, +) + +val recentCompletedRequests = mutableListOf() +var recentCompletedFailure: Throwable? = null +var mostRecentCompletedExerciseFailure: Throwable? = null + +override suspend fun getRecentCompletedSessionsForExercise( + exerciseId: String, + profileId: String, + limit: Int, +): List { + require(exerciseId.isNotBlank()) + require(profileId.isNotBlank()) + require(limit in 1..MAX_RECENT_EXERCISE_SESSIONS) + recentCompletedRequests += RecentCompletedRequest(exerciseId, profileId, limit) + recentCompletedFailure?.let { throw it } + return sessions.values + .asSequence() + .filter { it.profileId == profileId && it.exerciseId == exerciseId } + .filter { it.workingReps > 0 || it.totalReps > 0 } + .sortedWith( + compareByDescending { it.timestamp } + .thenByDescending { it.id }, + ) + .take(limit) + .toList() +} + +override suspend fun getMostRecentCompletedExerciseId(profileId: String): String? { + require(profileId.isNotBlank()) + mostRecentCompletedExerciseFailure?.let { throw it } + return sessions.values + .asSequence() + .filter { it.profileId == profileId } + .filter { it.workingReps > 0 || it.totalReps > 0 } + .filter { !it.exerciseId.isNullOrBlank() } + .sortedWith( + compareByDescending { it.timestamp } + .thenByDescending { it.id }, + ) + .firstOrNull() + ?.exerciseId +} +``` + +Also clear `recentCompletedRequests` and reset both failures to null inside the fake's existing `reset()`. The fake has no `deletedAt` field on `WorkoutSession`; its delete helper removes rows, while the SQLDelight tests own tombstone filtering. Do not silently accept `take(0)` or `take(-1)` in the fake. + +- [ ] **Step 5: Generate SQLDelight interfaces and make query tests green** + +Run: + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:generateCommonMainVitruvianDatabaseInterface :shared:testAndroidHostTest --tests "*SqlDelightWorkoutRepositoryTest*" --console=plain +``` + +Expected: BUILD SUCCESSFUL; all three new repository tests pass, including deterministic ties, tombstones, total-reps-only eligibility, and validation parity. + +- [ ] **Step 6: Write failing resolver precedence and normalization tests** + +First extend the Task 1/read fakes with failures that are thrown before returning data: + +```kotlin +// FakeVelocityOneRepMaxRepository +var latestPassingFailure: Throwable? = null + +override suspend fun getLatestPassing( + exerciseId: String, + profileId: String, +): VelocityOneRepMaxEntity? { + latestPassingFailure?.let { throw it } + return latestPassing?.takeIf { + it.exerciseId == exerciseId && it.profileId == profileId + } +} + +// FakeAssessmentRepository +var latestAssessmentFailure: Throwable? = null + +override suspend fun getLatestAssessment( + exerciseId: String, + profileId: String, +): AssessmentResultEntity? { + latestAssessmentFailure?.let { throw it } + return assessments + .filter { it.exerciseId == exerciseId && it.profileId == profileId } + .maxByOrNull { it.createdAt } +} +``` + +Do not add fallback behavior to these fakes: failures must let resolver and Exercise Detail tests prove their explicit error contracts. + +```kotlin +package com.devil.phoenixproject.domain.usecase + +import com.devil.phoenixproject.data.repository.VelocityOneRepMaxEntity +import com.devil.phoenixproject.data.repository.MAX_RECENT_EXERCISE_SESSIONS +import com.devil.phoenixproject.domain.model.WorkoutSession +import com.devil.phoenixproject.testutil.FakeAssessmentRepository +import com.devil.phoenixproject.testutil.FakeVelocityOneRepMaxRepository +import com.devil.phoenixproject.testutil.FakeWorkoutRepository +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.test.runTest + +class ResolveCurrentOneRepMaxUseCaseTest { + private val velocity = FakeVelocityOneRepMaxRepository() + private val assessments = FakeAssessmentRepository() + private val workouts = FakeWorkoutRepository() + private val resolver = ResolveCurrentOneRepMaxUseCase(velocity, assessments, workouts) + + @Test + fun `velocity wins over assessment and session`() = runTest { + seedAssessment(totalKg = 120f) + seedSession(perCableKg = 50f, reps = 5) + velocity.latestPassing = VelocityOneRepMaxEntity( + id = 1L, + exerciseId = "bench", + estimatedPerCableKg = 70f, + mvtUsedMs = 0.3f, + r2 = 0.95f, + distinctLoads = 3, + passedQualityGate = true, + computedAt = 30L, + profileId = "athlete-a", + ) + + assertEquals( + CurrentOneRepMax(70f, CurrentOneRepMaxSource.VELOCITY, 30L), + resolver("bench", "athlete-a"), + ) + assertEquals(emptyList(), workouts.recentCompletedRequests) + } + + @Test + fun `assessment override total is normalized to per cable`() = runTest { + assessments.saveAssessment( + exerciseId = "bench", + estimatedOneRepMaxKg = 120f, + loadVelocityDataJson = "[]", + sessionId = null, + userOverrideKg = 140f, + profileId = "athlete-a", + ) + + assertEquals(70f, resolver("bench", "athlete-a")?.perCableKg) + assertEquals(CurrentOneRepMaxSource.ASSESSMENT, resolver("bench", "athlete-a")?.source) + } + + @Test + fun `invalid assessment override falls through to its valid estimate`() = runTest { + seedAssessment(totalKg = 120f, overrideKg = Float.NaN) + + assertEquals( + CurrentOneRepMax(60f, CurrentOneRepMaxSource.ASSESSMENT, 1L), + resolver("bench", "athlete-a"), + ) + } + + @Test + fun `invalid velocity falls through to valid assessment`() = runTest { + seedAssessment(totalKg = 120f) + velocity.latestPassing = velocityEstimate( + perCableKg = Float.POSITIVE_INFINITY, + profileId = "athlete-a", + exerciseId = "bench", + ) + + assertEquals(CurrentOneRepMaxSource.ASSESSMENT, resolver("bench", "athlete-a")?.source) + assertEquals(60f, resolver("bench", "athlete-a")?.perCableKg) + } + + @Test + fun `session fallback uses canonical hybrid and never another profile`() = runTest { + seedSession(perCableKg = 100f, reps = 5, profileId = "athlete-b", timestamp = 40L) + seedSession(perCableKg = 100f, reps = 5, profileId = "athlete-a", timestamp = 20L) + + val result = resolver("bench", "athlete-a") + + assertEquals(112.5f, result?.perCableKg) + assertEquals(CurrentOneRepMaxSource.SESSION, result?.source) + assertEquals(20L, result?.measuredAt) + } + + @Test + fun `session fallback skips newest invalid estimate and uses newest valid bounded row`() = runTest { + seedSession(perCableKg = 100f, reps = 5, timestamp = 20L) + seedSession(perCableKg = Float.NaN, reps = 5, timestamp = 30L) + + val result = resolver("bench", "athlete-a") + + assertEquals(112.5f, result?.perCableKg) + assertEquals(20L, result?.measuredAt) + assertEquals( + FakeWorkoutRepository.RecentCompletedRequest( + exerciseId = "bench", + profileId = "athlete-a", + limit = MAX_RECENT_EXERCISE_SESSIONS, + ), + workouts.recentCompletedRequests.single(), + ) + } + + @Test + fun `session helper uses total reps fallback and rejects invalid load or reps`() { + val base = session(perCableKg = 100f, workingReps = 0, totalReps = 5) + + assertEquals(112.5f, base.estimatedOneRepMaxPerCableOrNull()) + assertNull(base.copy(weightPerCableKg = Float.NaN).estimatedOneRepMaxPerCableOrNull()) + assertNull(base.copy(weightPerCableKg = 0f).estimatedOneRepMaxPerCableOrNull()) + assertNull(base.copy(workingReps = 0, totalReps = 0).estimatedOneRepMaxPerCableOrNull()) + assertNull(base.copy(workingReps = -1, totalReps = -1).estimatedOneRepMaxPerCableOrNull()) + } + + @Test + fun `wrong profile and exercise at higher sources cannot block current session`() = runTest { + velocity.latestPassing = velocityEstimate( + perCableKg = 200f, + profileId = "athlete-b", + exerciseId = "squat", + ) + seedAssessment( + totalKg = 400f, + profileId = "athlete-b", + exerciseId = "squat", + ) + seedSession(perCableKg = 100f, reps = 5, profileId = "athlete-a", timestamp = 20L) + + assertEquals(CurrentOneRepMaxSource.SESSION, resolver("bench", "athlete-a")?.source) + } + + @Test + fun `only five newest sessions are inspected and all invalid sources return null`() = runTest { + velocity.latestPassing = velocityEstimate(perCableKg = Float.NaN) + seedAssessment(totalKg = -1f, overrideKg = Float.POSITIVE_INFINITY) + seedSession(perCableKg = 100f, reps = 5, timestamp = 1L) + repeat(MAX_RECENT_EXERCISE_SESSIONS) { index -> + seedSession(perCableKg = Float.NaN, reps = 5, timestamp = 10L + index) + } + + assertNull(resolver("bench", "athlete-a")) + assertEquals(MAX_RECENT_EXERCISE_SESSIONS, workouts.recentCompletedRequests.single().limit) + } + + @Test + fun `blank IDs fail before reading any source`() = runTest { + assertFailsWith { resolver(" ", "athlete-a") } + assertFailsWith { resolver("bench", " ") } + assertEquals(emptyList(), workouts.recentCompletedRequests) + } + + @Test + fun `fake bounded read validation mirrors production`() = runTest { + assertFailsWith { + workouts.getRecentCompletedSessionsForExercise(" ", "athlete-a", 1) + } + assertFailsWith { + workouts.getRecentCompletedSessionsForExercise("bench", " ", 1) + } + listOf(0, MAX_RECENT_EXERCISE_SESSIONS + 1).forEach { limit -> + assertFailsWith { + workouts.getRecentCompletedSessionsForExercise("bench", "athlete-a", limit) + } + } + assertFailsWith { + workouts.getMostRecentCompletedExerciseId(" ") + } + assertEquals(emptyList(), workouts.recentCompletedRequests) + } + + @Test + fun `ordinary higher source failure propagates instead of selecting a lower source`() = runTest { + seedAssessment(totalKg = 120f) + velocity.latestPassingFailure = IllegalStateException("velocity unavailable") + + val thrown = assertFailsWith { + resolver("bench", "athlete-a") + } + assertEquals("velocity unavailable", thrown.message) + } + + @Test + fun `cancellation from any source propagates`() = runTest { + assessments.latestAssessmentFailure = CancellationException("profile changed") + + val thrown = assertFailsWith { + resolver("bench", "athlete-a") + } + assertEquals("profile changed", thrown.message) + } + + @Test + fun `session read failure propagates when higher sources are absent`() = runTest { + workouts.recentCompletedFailure = IllegalStateException("history unavailable") + + val thrown = assertFailsWith { + resolver("bench", "athlete-a") + } + assertEquals("history unavailable", thrown.message) + } + + private suspend fun seedAssessment( + totalKg: Float, + overrideKg: Float? = null, + exerciseId: String = "bench", + profileId: String = "athlete-a", + ) { + assessments.saveAssessment( + exerciseId, + totalKg, + "[]", + null, + overrideKg, + profileId, + ) + } + + private fun seedSession( + perCableKg: Float, + reps: Int, + profileId: String = "athlete-a", + timestamp: Long = 10L, + ) { + workouts.addSession( + session( + perCableKg = perCableKg, + workingReps = reps, + totalReps = reps, + profileId = profileId, + timestamp = timestamp, + ), + ) + } + + private fun session( + perCableKg: Float, + workingReps: Int, + totalReps: Int, + profileId: String = "athlete-a", + exerciseId: String = "bench", + timestamp: Long = 10L, + ) = WorkoutSession( + id = "$profileId-$exerciseId-$timestamp-$perCableKg", + timestamp = timestamp, + mode = "OldSchool", + reps = totalReps, + weightPerCableKg = perCableKg, + duration = 1_000L, + totalReps = totalReps, + workingReps = workingReps, + exerciseId = exerciseId, + exerciseName = exerciseId, + profileId = profileId, + ) + + private fun velocityEstimate( + perCableKg: Float, + profileId: String = "athlete-a", + exerciseId: String = "bench", + ) = VelocityOneRepMaxEntity( + id = 1L, + exerciseId = exerciseId, + estimatedPerCableKg = perCableKg, + mvtUsedMs = 0.3f, + r2 = 0.95f, + distinctLoads = 3, + passedQualityGate = true, + computedAt = 30L, + profileId = profileId, + ) +} +``` + +- [ ] **Step 7: Run resolver tests and confirm the red state** + +Run: + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest --tests "*ResolveCurrentOneRepMaxUseCaseTest*" --console=plain +``` + +Expected: FAIL to compile because the result/source/use-case types do not exist. + +- [ ] **Step 8: Implement the shared resolver** + +```kotlin +package com.devil.phoenixproject.domain.usecase + +import com.devil.phoenixproject.data.repository.AssessmentRepository +import com.devil.phoenixproject.data.repository.MAX_RECENT_EXERCISE_SESSIONS +import com.devil.phoenixproject.data.repository.VelocityOneRepMaxRepository +import com.devil.phoenixproject.data.repository.WorkoutRepository +import com.devil.phoenixproject.domain.model.WorkoutSession +import com.devil.phoenixproject.util.OneRepMaxCalculator + +enum class CurrentOneRepMaxSource { + VELOCITY, + ASSESSMENT, + SESSION, +} + +data class CurrentOneRepMax( + val perCableKg: Float, + val source: CurrentOneRepMaxSource, + val measuredAt: Long, +) + +fun WorkoutSession.estimatedOneRepMaxPerCableOrNull(): Float? { + val load = weightPerCableKg.takeIf { it.isFinite() && it > 0f } ?: return null + val reps = workingReps.takeIf { it > 0 } + ?: totalReps.takeIf { it > 0 } + ?: return null + return OneRepMaxCalculator.estimate(load, reps) + .takeIf { it.isFinite() && it > 0f } +} + +private fun Float.validPositiveOrNull(): Float? = + takeIf { it.isFinite() && it > 0f } + +class ResolveCurrentOneRepMaxUseCase( + private val velocityRepository: VelocityOneRepMaxRepository, + private val assessmentRepository: AssessmentRepository, + private val workoutRepository: WorkoutRepository, +) { + suspend operator fun invoke(exerciseId: String, profileId: String): CurrentOneRepMax? { + require(exerciseId.isNotBlank()) + require(profileId.isNotBlank()) + + velocityRepository.getLatestPassing(exerciseId, profileId) + ?.takeIf { + it.exerciseId == exerciseId && + it.profileId == profileId && + it.passedQualityGate + } + ?.let { estimate -> + estimate.estimatedPerCableKg.validPositiveOrNull() + ?.let { estimate to it } + } + ?.let { + return CurrentOneRepMax( + perCableKg = it.second, + source = CurrentOneRepMaxSource.VELOCITY, + measuredAt = it.first.computedAt, + ) + } + + assessmentRepository.getLatestAssessment(exerciseId, profileId)?.let { assessment -> + if (assessment.exerciseId == exerciseId && assessment.profileId == profileId) { + val validTotalKg = assessment.userOverrideKg?.validPositiveOrNull() + ?: assessment.estimatedOneRepMaxKg.validPositiveOrNull() + val perCableKg = validTotalKg?.div(2f)?.validPositiveOrNull() + if (perCableKg != null) { + return CurrentOneRepMax( + perCableKg = perCableKg, + source = CurrentOneRepMaxSource.ASSESSMENT, + measuredAt = assessment.createdAt, + ) + } + } + } + + workoutRepository.getRecentCompletedSessionsForExercise( + exerciseId = exerciseId, + profileId = profileId, + limit = MAX_RECENT_EXERCISE_SESSIONS, + ).forEach { session -> + if (session.exerciseId == exerciseId && session.profileId == profileId) { + val estimate = session.estimatedOneRepMaxPerCableOrNull() + if (estimate != null) { + return CurrentOneRepMax( + perCableKg = estimate, + source = CurrentOneRepMaxSource.SESSION, + measuredAt = session.timestamp, + ) + } + } + } + return null + } +} +``` + +There is intentionally no `try/catch` in the resolver. A failure at velocity or assessment means precedence is unknown; a session read failure means fallback is unavailable. All ordinary failures and `CancellationException` therefore propagate. Invalid values are data fallthrough, not exceptions. The shared session extension is the only session-estimate calculation used by both this resolver and Exercise Detail. + +- [ ] **Step 9: Register the use case and make resolver tests green** + +Add to `DomainModule.kt`: + +```kotlin +import com.devil.phoenixproject.domain.usecase.ResolveCurrentOneRepMaxUseCase + +single { ResolveCurrentOneRepMaxUseCase(get(), get(), get()) } +``` + +Run: + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest --tests "*ResolveCurrentOneRepMaxUseCaseTest*" --tests "*KoinModuleVerifyTest*" --rerun --console=plain +``` + +Expected: BUILD SUCCESSFUL; the resolver suite and existing `KoinModuleVerifyTest.verifyAppModule` pass without adding the use case to `extraTypes`. It is an application-owned binding whose three repositories already resolve from `appModule`. + +- [ ] **Step 10: Write failing Exercise Detail latest-request, cancellation, and error-isolation tests** + +Create `ExerciseDetailOneRepMaxLoadTest.kt`: + +```kotlin +package com.devil.phoenixproject.presentation.screen + +import com.devil.phoenixproject.domain.usecase.CurrentOneRepMax +import com.devil.phoenixproject.domain.usecase.CurrentOneRepMaxSource +import com.devil.phoenixproject.testutil.readProjectFile +import kotlin.coroutines.cancellation.CancellationException +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest + +class ExerciseDetailOneRepMaxLoadTest { + private val requestA = ExerciseDetailOneRepMaxRequest("bench", "athlete-a", emptyList()) + private val requestB = ExerciseDetailOneRepMaxRequest("bench", "athlete-b", emptyList()) + private val resultA = CurrentOneRepMax(50f, CurrentOneRepMaxSource.SESSION, 10L) + private val resultB = CurrentOneRepMax(60f, CurrentOneRepMaxSource.ASSESSMENT, 20L) + + @Test + fun `late completion from A cannot overwrite Ready B`() = runTest { + val gate = ExerciseDetailOneRepMaxLoadGate() + val states = mutableListOf() + val aStarted = CompletableDeferred() + val releaseA = CompletableDeferred() + val loadA = async { + loadExerciseDetailOneRepMax( + request = requestA, + gate = gate, + resolve = { _, _ -> + aStarted.complete(Unit) + releaseA.await() + resultA + }, + publish = states::add, + ) + } + runCurrent() + aStarted.await() + + loadExerciseDetailOneRepMax( + request = requestB, + gate = gate, + resolve = { _, _ -> resultB }, + publish = states::add, + ) + releaseA.complete(Unit) + loadA.await() + + assertEquals( + listOf( + ExerciseDetailOneRepMaxState.Loading, + ExerciseDetailOneRepMaxState.Loading, + ExerciseDetailOneRepMaxState.Ready(resultB), + ), + states, + ) + } + + @Test + fun `late error from A cannot replace Ready B with Failed`() = runTest { + val gate = ExerciseDetailOneRepMaxLoadGate() + val states = mutableListOf() + val aStarted = CompletableDeferred() + val releaseA = CompletableDeferred() + val loadA = async { + loadExerciseDetailOneRepMax( + request = requestA, + gate = gate, + resolve = { _, _ -> + aStarted.complete(Unit) + releaseA.await() + error("late A failure") + }, + publish = states::add, + ) + } + runCurrent() + aStarted.await() + loadExerciseDetailOneRepMax( + request = requestB, + gate = gate, + resolve = { _, _ -> resultB }, + publish = states::add, + ) + releaseA.complete(Unit) + loadA.await() + + assertEquals(ExerciseDetailOneRepMaxState.Ready(resultB), states.last()) + assertFalse(states.contains(ExerciseDetailOneRepMaxState.Failed)) + } + + @Test + fun `cancellation escapes and is never rendered as failure`() = runTest { + val states = mutableListOf() + val started = CompletableDeferred() + val child = async { + loadExerciseDetailOneRepMax( + request = requestA, + gate = ExerciseDetailOneRepMaxLoadGate(), + resolve = { _, _ -> + started.complete(Unit) + awaitCancellation() + }, + publish = states::add, + ) + } + runCurrent() + started.await() + + val cause = CancellationException("profile switched") + child.cancel(cause) + val thrown = assertFailsWith { child.await() } + + assertEquals(cause.message, thrown.message) + assertEquals(listOf(ExerciseDetailOneRepMaxState.Loading), states) + } + + @Test + fun `ordinary current-request error fails only the one rep max branch`() = runTest { + val states = mutableListOf() + + loadExerciseDetailOneRepMax( + request = requestA, + gate = ExerciseDetailOneRepMaxLoadGate(), + resolve = { _, _ -> error("resolver unavailable") }, + publish = states::add, + ) + + assertEquals( + listOf( + ExerciseDetailOneRepMaxState.Loading, + ExerciseDetailOneRepMaxState.Failed, + ), + states, + ) + } + + @Test + fun `screen has one resolver path and no legacy profile or velocity read`() { + val source = readProjectFile( + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailScreen.kt", + ) + assertNotNull(source) + assertContains(source, "assessmentProfileId") + assertContains(source, "loadExerciseDetailOneRepMax(") + assertContains(source, "estimatedOneRepMaxPerCableOrNull()") + assertContains(source, "catch (cancellation: CancellationException)") + assertContains(source, "catch (_: Exception)") + assertFalse(source.contains("catch (_: Throwable)")) + assertContains(source, "VolumeChartCard(") + assertContains(source, "items(exerciseSessions") + assertFalse(source.contains("viewModel.activeProfileId")) + assertFalse(source.contains("velocityOneRepMaxRepository")) + assertFalse(source.contains("VelocityOneRepMaxEntity")) + } +} +``` + +This uses a real canceled child and compares cancellation type/message rather than object identity. The stale test intentionally leaves A running while B completes; only the current token may publish. + +- [ ] **Step 11: Replace Exercise Detail's split hero with a token-gated shared-resolver load** + +Add these internal presentation types/functions in `ExerciseDetailScreen.kt` above the composable: + +```kotlin +internal data class ExerciseDetailOneRepMaxRequest( + val exerciseId: String, + val profileId: String, + val completedSessions: List, +) + +internal data class ExerciseDetailOneRepMaxLoadToken( + val generation: Long, + val request: ExerciseDetailOneRepMaxRequest, +) + +internal class ExerciseDetailOneRepMaxLoadGate { + private var generation = 0L + private var active: ExerciseDetailOneRepMaxLoadToken? = null + + fun begin(request: ExerciseDetailOneRepMaxRequest): ExerciseDetailOneRepMaxLoadToken = + ExerciseDetailOneRepMaxLoadToken(++generation, request).also { active = it } + + fun isCurrent(token: ExerciseDetailOneRepMaxLoadToken): Boolean = active == token +} + +internal sealed interface ExerciseDetailOneRepMaxState { + data object Loading : ExerciseDetailOneRepMaxState + data class Ready(val resolution: CurrentOneRepMax?) : ExerciseDetailOneRepMaxState + data object Failed : ExerciseDetailOneRepMaxState +} + +internal suspend fun loadExerciseDetailOneRepMax( + request: ExerciseDetailOneRepMaxRequest, + gate: ExerciseDetailOneRepMaxLoadGate, + resolve: suspend (exerciseId: String, profileId: String) -> CurrentOneRepMax?, + publish: (ExerciseDetailOneRepMaxState) -> Unit, +) { + val token = gate.begin(request) + publish(ExerciseDetailOneRepMaxState.Loading) + try { + val resolution = resolve(request.exerciseId, request.profileId) + if (gate.isCurrent(token)) { + publish(ExerciseDetailOneRepMaxState.Ready(resolution)) + } + } catch (cancellation: CancellationException) { + throw cancellation + } catch (_: Exception) { + if (gate.isCurrent(token)) { + publish(ExerciseDetailOneRepMaxState.Failed) + } + } +} +``` + +The gate is confined to the Compose effect context; it is a latest-request token, not a cross-thread lock. The request includes the exact completed-session snapshot so a save/edit for the same exercise/profile creates a new generation. In `ExerciseDetailScreen`: + +1. Inject `ResolveCurrentOneRepMaxUseCase`. Delete the `VelocityOneRepMaxEntity` import, direct `velocityOneRepMaxRepository.getLatestPassing` state/effect, and `viewModel.activeProfileId` collection. +2. Treat Task 1's nullable `assessmentProfileId` parameter as the only Ready profile source. While it is null, render no old resolution and make no resolver call. +3. Filter local sessions by exact profile, exercise, and completion, then order ties deterministically: + +```kotlin +val profileId = assessmentProfileId +val exerciseSessions = remember(allWorkoutSessions, exerciseId, profileId) { + if (profileId == null) { + emptyList() + } else { + allWorkoutSessions + .asSequence() + .filter { it.profileId == profileId && it.exerciseId == exerciseId } + .filter { it.workingReps > 0 || it.totalReps > 0 } + .sortedWith( + compareByDescending { it.timestamp } + .thenByDescending { it.id }, + ) + .toList() + } +} +``` + +4. Key the state holder itself by the full request, clearing stale UI synchronously before the new effect runs; use the load gate as a second defense against non-cooperative old reads: + +```kotlin +val resolveCurrentOneRepMax: ResolveCurrentOneRepMaxUseCase = koinInject() +val loadGate = remember { ExerciseDetailOneRepMaxLoadGate() } +val request = remember(exerciseId, profileId, exerciseSessions) { + profileId?.let { + ExerciseDetailOneRepMaxRequest( + exerciseId = exerciseId, + profileId = it, + completedSessions = exerciseSessions, + ) + } +} +val stateHolder = remember(request) { + mutableStateOf( + ExerciseDetailOneRepMaxState.Loading, + ) +} +val oneRepMaxState by stateHolder + +LaunchedEffect(request, resolveCurrentOneRepMax) { + val currentRequest = request ?: return@LaunchedEffect + loadExerciseDetailOneRepMax( + request = currentRequest, + gate = loadGate, + resolve = resolveCurrentOneRepMax::invoke, + publish = { stateHolder.value = it }, + ) +} +``` + +5. Use `WorkoutSession.estimatedOneRepMaxPerCableOrNull()` for chart and delta data. Never duplicate working-reps/load validation: + +```kotlin +val validSessionEstimatesNewestFirst = remember(exerciseSessions) { + exerciseSessions.mapNotNull { session -> + session.estimatedOneRepMaxPerCableOrNull() + ?.let { estimate -> session.timestamp to estimate } + } +} +val oneRepMaxData = remember(validSessionEstimatesNewestFirst) { + validSessionEstimatesNewestFirst.reversed() +} +val previousSessionOneRepMax = + validSessionEstimatesNewestFirst.getOrNull(1)?.second +``` + +Change `OneRepMaxCard` to accept `state: ExerciseDetailOneRepMaxState` and `previousSessionOneRepMax`. Extract a resolution only from `Ready`. Show a delta only when its source is `SESSION`; `Loading` and `Failed` affect only this card and must not hide the history list/charts or assessment button. Keep the assessment action visible regardless of `vbtEnabled`. Task 3 replaces temporary source/empty/error labels with localized resources. + +- [ ] **Step 12: Run exact focused suites, inspect XML counts, compile callers, and scan legacy paths** + +Run generation plus focused tests: + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:generateCommonMainVitruvianDatabaseInterface :shared:testAndroidHostTest --tests "*SqlDelightWorkoutRepositoryTest*" --tests "*ResolveCurrentOneRepMaxUseCaseTest*" --tests "*ExerciseDetailOneRepMaxLoadTest*" --tests "*KoinModuleVerifyTest*" --tests "*VelocityOneRepMaxRepositoryTest*" --tests "*OneRepMaxCalculatorTest*" --rerun --console=plain +``` + +Expected: BUILD SUCCESSFUL. Verify every exact requested class executed at least one JUnit case: + +```powershell +$resultRoot = 'shared/build/test-results/testAndroidHostTest' +$documents = @( + Get-ChildItem $resultRoot -Filter 'TEST-*.xml' | + ForEach-Object { [xml](Get-Content $_.FullName -Raw) } +) +$expectedClasses = @( + 'com.devil.phoenixproject.data.repository.SqlDelightWorkoutRepositoryTest', + 'com.devil.phoenixproject.domain.usecase.ResolveCurrentOneRepMaxUseCaseTest', + 'com.devil.phoenixproject.presentation.screen.ExerciseDetailOneRepMaxLoadTest', + 'com.devil.phoenixproject.di.KoinModuleVerifyTest', + 'com.devil.phoenixproject.data.repository.VelocityOneRepMaxRepositoryTest', + 'com.devil.phoenixproject.util.OneRepMaxCalculatorTest' +) +foreach ($className in $expectedClasses) { + $count = 0 + foreach ($document in $documents) { + $count += @( + $document.SelectNodes("//testcase[@classname='$className']") + ).Count + } + if ($count -lt 1) { + throw "No executed test cases found for $className" + } + Write-Output "$className : $count" +} +``` + +Compile production and test callers for both configured platform families: + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:compileAndroidMain :shared:compileAndroidHostTest :shared:compileKotlinIosArm64 :shared:compileTestKotlinIosArm64 --console=plain +``` + +Expected: BUILD SUCCESSFUL. Then run exact implementation/caller scans: + +```powershell +rg -n 'getRecentCompletedSessionsForExercise|getMostRecentCompletedExerciseId' shared/src --glob '*.kt' +rg -n 'ResolveCurrentOneRepMaxUseCase' shared/src --glob '*.kt' + +$legacyDetailReads = rg -n 'viewModel\.activeProfileId|velocityOneRepMaxRepository|VelocityOneRepMaxEntity' shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailScreen.kt +if ($LASTEXITCODE -eq 0) { + $legacyDetailReads + throw 'Exercise Detail still has a legacy profile or velocity read' +} + +$newestOnlyResolver = rg -n -U 'getRecentCompletedSessionsForExercise\([\s\S]{0,300}?limit\s*=\s*1' shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCase.kt +if ($LASTEXITCODE -eq 0) { + $newestOnlyResolver + throw 'Resolver still inspects only the newest session' +} + +$implementationRoots = @( + 'shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository', + 'shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil' +) +$implementations = @( + rg -l -U 'class\s+\w+[^\{]*:\s*WorkoutRepository' $implementationRoots --glob '*.kt' | + ForEach-Object { $_ -replace '\\', '/' } | + Sort-Object +) +$expectedImplementations = @( + 'shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/SqlDelightWorkoutRepository.kt', + 'shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeWorkoutRepository.kt' +) | Sort-Object +if (Compare-Object $expectedImplementations $implementations) { + Compare-Object $expectedImplementations $implementations + throw 'WorkoutRepository implementation inventory changed' +} + +$resolverBindings = @( + rg -n 'single\s*\{\s*ResolveCurrentOneRepMaxUseCase\(' shared/src/commonMain/kotlin/com/devil/phoenixproject/di/DomainModule.kt +) +if ($resolverBindings.Count -ne 1) { + $resolverBindings + throw "Expected one resolver binding, found $($resolverBindings.Count)" +} + +$resolverProductionFiles = @( + rg -l 'ResolveCurrentOneRepMaxUseCase' shared/src/commonMain --glob '*.kt' | + ForEach-Object { $_ -replace '\\', '/' } | + Sort-Object +) +$expectedResolverProductionFiles = @( + 'shared/src/commonMain/kotlin/com/devil/phoenixproject/di/DomainModule.kt', + 'shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCase.kt', + 'shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailScreen.kt' +) | Sort-Object +if (Compare-Object $expectedResolverProductionFiles $resolverProductionFiles) { + Compare-Object $expectedResolverProductionFiles $resolverProductionFiles + throw 'Resolver production caller inventory changed' +} +``` + +Expected at Task 2 completion: bounded-method callers are the interface, production/fake repositories, resolver, and focused tests; Task 5 later adds `ProfileViewModel`. The resolver is bound exactly once in `DomainModule` and consumed by Exercise Detail plus focused tests; Task 5 later adds the Profile consumer. The implementation list is exactly `SqlDelightWorkoutRepository` and `FakeWorkoutRepository`. + +- [ ] **Step 13: Commit the query/resolver slice** + +```powershell +git add shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/VitruvianDatabase.sq shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/WorkoutRepository.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/SqlDelightWorkoutRepository.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCase.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/di/DomainModule.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailScreen.kt shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCaseTest.kt shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailOneRepMaxLoadTest.kt shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeWorkoutRepository.kt shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeVelocityOneRepMaxRepository.kt shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeAssessmentRepository.kt shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightWorkoutRepositoryTest.kt +git commit -m "feat: resolve profile scoped exercise one rep max" +``` + +--- + +### Task 3: Add Localized Profile, Navigation, Insight, and Error Copy + +**Files:** +- Create: `shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ProfileResourceContractTest.kt` +- Modify: `shared/src/commonMain/composeResources/values/strings.xml` +- Modify: `shared/src/commonMain/composeResources/values-nl/strings.xml` +- Modify: `shared/src/commonMain/composeResources/values-de/strings.xml` +- Modify: `shared/src/commonMain/composeResources/values-es/strings.xml` +- Modify: `shared/src/commonMain/composeResources/values-fr/strings.xml` + +**Interfaces:** +- Consumes: existing shared resource keys for actions, profile colors, Exercise Picker, Equipment Rack, voice stop, and adult-mode controls. +- Produces: 56 stable generated Profile/navigation/insight/error keys, each assigned to an exact Task 4–9 consumer contract. + +The original draft listed 59 new keys. The audit intentionally removes three duplicate concepts and reuses existing resources in all five selectable locales: + +| Profile concept | Reused resource | Exact downstream rendering | +|---|---|---| +| Weight unit | `settings_weight_unit` | Measurements control label in `ProfilePreferenceComponents.kt` | +| Equipment Rack | `equipment_rack_title` + `equipment_rack_manage` | Rack row headline + action in `ProfilePreferenceComponents.kt` | +| LED color scheme | `cd_led_scheme` | LED selector label in `ProfilePreferenceComponents.kt`; Task 3 also completes its German, Spanish, and French translations. | + +Keep `profile_one_rep_max_source_assessment` even though its English value matches `strength_assessment_cta_title`: the latter exists only in the default bundle, so reusing it would leave the four other selectable locales without translated source copy. + +Do not add `profile_weight_unit`, `profile_manage_equipment_rack`, or `profile_led_color_scheme`. `values-it` exists for system-locale coverage but is not selectable; missing Task 3 keys there use the default English fallback. `SettingsTab.languageOptions` exposes exactly `en`, `nl`, `de`, `es`, and `fr`, so this contract modifies and verifies those five selectable locales only. If Italian becomes selectable later, that change must extend this contract in the same commit. + +The retained new-key ownership is exact: + +| Consumer task/file | Required new keys | +|---|---| +| Task 4: `ProfileSwitcherSheet.kt`, `ProfileDialogs.kt` | `profiles_title`, `profile_delete_reassign_message` | +| Task 5: `ProfileViewModel.kt` | None; ViewModels emit typed state/events and never own localized copy. | +| Task 6: `ProfileScreen.kt`, `ProfileExerciseInsights.kt` | `switch_profile`, `profile_exercise_insights`, `profile_choose_exercise`, `profile_no_exercise_history`, `profile_current_one_rep_max`, `profile_one_rep_max_source_velocity`, `profile_one_rep_max_source_assessment`, `profile_one_rep_max_source_session`, `profile_one_rep_max_source_none`, `profile_pr_highlights`, `profile_pr_max_weight`, `profile_pr_estimated_one_rep_max`, `profile_pr_max_volume`, `profile_recent_history`, `profile_view_full_history`, `profile_switching`, `profile_missing_exercise`, `profile_insights_load_failed`, `profile_update_failed` | +| Task 7: `EnhancedMainScreen.kt`, `NavGraph.kt`, `ProfileDialogs.kt` | `nav_profile`, `cd_profile`, `cd_open_profile_switcher`, `profile_switch_failed`, `profile_create_failed`, `profile_recovery_title`, `profile_recovery_message`, `profile_recovery_retry_failed` | +| Task 8: `ProfilePreferenceComponents.kt`, `ProfileScreen.kt` | `profile_preferences_title`, `profile_measurements`, `profile_workout_behavior`, `profile_led`, `profile_vbt`, `profile_safety`, `profile_vbt_enabled`, `profile_weight_increment`, `profile_body_weight`, `profile_set_summary`, `profile_autostart_countdown`, `profile_auto_start_routine`, `profile_audio_rep_counter`, `profile_countdown_beeps`, `profile_rep_completion_sound`, `profile_motion_start`, `profile_gamification`, `profile_default_scaling_basis`, `profile_routine_starting_weights`, `profile_stop_at_top`, `profile_stall_detection`, `profile_velocity_loss_threshold`, `profile_auto_end_velocity_loss`, `profile_vbt_history_note` | +| Task 9: `SettingsTab.kt` | `settings_video_behavior`, `settings_show_exercise_videos`, `settings_show_exercise_videos_description` | +| Task 10: sole-owner regression guard | None; it is test-only and introduces no copy. | + +- [ ] **Step 1: Write the failing locale resource contract test** + +```kotlin +package com.devil.phoenixproject.presentation.screen + +import com.devil.phoenixproject.testutil.readProjectFile +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class ProfileResourceContractTest { + private val selectableLocales = linkedMapOf( + "en" to "values", + "nl" to "values-nl", + "de" to "values-de", + "es" to "values-es", + "fr" to "values-fr", + ) + + private val files = selectableLocales.mapValues { (_, directory) -> + "src/commonMain/composeResources/$directory/strings.xml" + } + + private val newKeys = listOf( + "nav_profile", + "cd_profile", + "cd_open_profile_switcher", + "profiles_title", + "switch_profile", + "profile_exercise_insights", + "profile_choose_exercise", + "profile_no_exercise_history", + "profile_current_one_rep_max", + "profile_one_rep_max_source_velocity", + "profile_one_rep_max_source_assessment", + "profile_one_rep_max_source_session", + "profile_one_rep_max_source_none", + "profile_pr_highlights", + "profile_pr_max_weight", + "profile_pr_estimated_one_rep_max", + "profile_pr_max_volume", + "profile_recent_history", + "profile_view_full_history", + "profile_preferences_title", + "profile_measurements", + "profile_workout_behavior", + "profile_led", + "profile_vbt", + "profile_safety", + "profile_vbt_enabled", + "profile_switching", + "profile_missing_exercise", + "profile_insights_load_failed", + "profile_update_failed", + "profile_switch_failed", + "profile_create_failed", + "profile_recovery_title", + "profile_recovery_message", + "profile_recovery_retry_failed", + "profile_delete_reassign_message", + "settings_video_behavior", + "settings_show_exercise_videos", + "settings_show_exercise_videos_description", + "profile_weight_increment", + "profile_body_weight", + "profile_set_summary", + "profile_autostart_countdown", + "profile_auto_start_routine", + "profile_audio_rep_counter", + "profile_countdown_beeps", + "profile_rep_completion_sound", + "profile_motion_start", + "profile_gamification", + "profile_default_scaling_basis", + "profile_routine_starting_weights", + "profile_stop_at_top", + "profile_stall_detection", + "profile_velocity_loss_threshold", + "profile_auto_end_velocity_loss", + "profile_vbt_history_note", + ) + + private val reusedKeys = listOf( + "settings_weight_unit", + "equipment_rack_title", + "equipment_rack_manage", + "cd_led_scheme", + "color_blue", + "color_green", + "color_amber", + "color_red", + "color_purple", + "color_pink", + "color_cyan", + "color_orange", + "cd_select_profile_color", + ) + + private val expectedLedSchemeLabels = mapOf( + "en" to "LED color scheme", + "nl" to "LED-kleurenschema", + "de" to "LED-Farbschema", + "es" to "Esquema de color LED", + "fr" to "Palette de couleurs LED", + ) + + private val expectedPlaceholders = mapOf( + "profile_delete_reassign_message" to listOf("%1${'$'}s"), + "cd_select_profile_color" to listOf("%1${'$'}s"), + ) + + private val expectedColorLabels = mapOf( + "en" to listOf("Blue", "Green", "Amber", "Red", "Purple", "Pink", "Cyan", "Orange"), + "nl" to listOf("Blauw", "Groen", "Amber", "Rood", "Paars", "Roze", "Cyaan", "Oranje"), + "de" to listOf("Blau", "Grün", "Bernstein", "Rot", "Lila", "Rosa", "Cyan", "Orange"), + "es" to listOf("Azul", "Verde", "Ámbar", "Rojo", "Morado", "Rosa", "Cian", "Naranja"), + "fr" to listOf("Bleu", "Vert", "Ambre", "Rouge", "Violet", "Rose", "Cyan", "Orange"), + ) + + private val placeholderPattern = Regex("""%\d+\${'$'}[A-Za-z]""") + private val invalidAmpersandPattern = + Regex("""&(?!amp;|lt;|gt;|quot;|apos;|#\d+;|#x[0-9A-Fa-f]+;)""") + + @Test + fun contractInventoryIsUniqueAndTracksExactlyFiveSelectableLocales() { + assertEquals(56, newKeys.size) + assertEquals(newKeys.size, newKeys.toSet().size) + assertTrue(newKeys.intersect(reusedKeys.toSet()).isEmpty()) + + val settings = assertNotNull( + readProjectFile( + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/SettingsTab.kt", + ), + ) + selectableLocales.keys.forEach { languageCode -> + assertContains(settings, "\"$languageCode\" to stringResource") + } + assertFalse(settings.contains("\"it\" to stringResource")) + } + + @Test + fun selectableLocalesContainOneWellFormedDeclarationPerContractKey() { + files.forEach { (languageCode, path) -> + val source = assertNotNull(readProjectFile(path), "Missing $path") + + val names = Regex(""" 1 } + assertTrue(duplicateNames.isEmpty(), "$path duplicates $duplicateNames") + assertFalse( + invalidAmpersandPattern.containsMatchIn(source), + "$path contains an unescaped XML ampersand", + ) + + (newKeys + reusedKeys).forEach { key -> + val declarationCount = Regex(""")""") + .findAll(source) + .count() + assertEquals(1, declarationCount, "$path declaration count for $key") + } + assertEquals( + expectedLedSchemeLabels.getValue(languageCode), + resourceValue(source, "cd_led_scheme", path), + "$path translated LED scheme label", + ) + assertEquals( + expectedColorLabels.getValue(languageCode), + listOf( + "color_blue", + "color_green", + "color_amber", + "color_red", + "color_purple", + "color_pink", + "color_cyan", + "color_orange", + ).map { resourceValue(source, it, path) }, + "$path translated profile color labels", + ) + + (newKeys + reusedKeys).forEach { key -> + val value = resourceValue(source, key, path) + val placeholders = placeholderPattern.findAll(value) + .map { it.value } + .toList() + assertEquals( + expectedPlaceholders[key].orEmpty(), + placeholders, + "$path placeholder contract for $key", + ) + } + } + } + + @Test + fun sourceReaderActualsSupportSharedRelativeContractPaths() { + val androidReader = assertNotNull( + readProjectFile( + "src/androidHostTest/kotlin/com/devil/phoenixproject/testutil/SourceFileReader.android.kt", + ), + ) + val iosReader = assertNotNull( + readProjectFile( + "src/iosTest/kotlin/com/devil/phoenixproject/testutil/SourceFileReader.ios.kt", + ), + ) + + assertContains(androidReader, """File(dir, "shared/${'$'}relativePath")""") + assertContains(iosReader, """candidates.add("${'$'}dir/shared/${'$'}relativePath")""") + } + + private fun resourceValue(source: String, key: String, path: String): String = + assertNotNull( + Regex( + """]*>(.*?)""", + RegexOption.DOT_MATCHES_ALL, + ).find(source)?.groupValues?.get(1), + "$path is missing the value for $key", + ) +} +``` + +- [ ] **Step 2: Run the locale contract and confirm the red state** + +Run: + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest --tests "*ProfileResourceContractTest*" --console=plain +``` + +Expected: FAIL because `nav_profile` and the remaining new resource keys are absent. + +- [ ] **Step 3: Add the complete English resource block** + +```xml +Profile +Profile +Open profile switcher +Profiles +Switch Profile +Exercise Insights +Choose an exercise +No exercise history for this profile +Current 1RM +Velocity estimate +Strength assessment +Recent completed session +No profile-scoped estimate +PR Highlights +Max weight +Estimated 1RM +Max volume +Recent History +View Full History +Preferences +Measurements +Workout Behavior +LED +Velocity-Based Training +Safety +Use VBT during live workouts +Switching profile… +This exercise is no longer available +Could not load exercise insights +Could not save this profile preference +Could not switch profiles +Could not create the profile +Profile recovery required +Phoenix could not confirm which profile is active. Retry before continuing. +Profile recovery is still unavailable +Delete "%1$s"? Its workouts, routines, records, badges, assessments, and progression data will move to Default. This cannot be undone. +Video Behavior +Show Exercise Videos +Display exercise demonstrations; turn this off on slower devices +Weight increment +Body weight +Set summary duration +Autostart countdown +Auto-start routine +Audio rep counter +Countdown beeps +Rep completion sound +Motion-triggered set start +Gamification +Default weight scaling basis +Routine starting weights +Stop at top +Stall detection +Velocity-loss threshold +Auto-end on velocity loss +Turning VBT off affects live workouts only; saved estimates and assessments remain available. +``` + +- [ ] **Step 4: Add the complete Dutch resource block** + +```xml +Profiel +Profiel +Profielwisselaar openen +Profielen +Profiel wisselen +Oefeningsinzichten +Kies een oefening +Geen oefeningsgeschiedenis voor dit profiel +Huidige 1RM +Snelheidsschatting +Krachtmeting +Recente voltooide sessie +Geen profielgebonden schatting +PR-hoogtepunten +Maximaal gewicht +Geschatte 1RM +Maximaal volume +Recente geschiedenis +Volledige geschiedenis bekijken +Voorkeuren +Metingen +Trainingsgedrag +LED +Snelheidsgebaseerde training +Veiligheid +VBT gebruiken tijdens live trainingen +Profiel wisselen… +Deze oefening is niet meer beschikbaar +Oefeningsinzichten konden niet worden geladen +Deze profielvoorkeur kon niet worden opgeslagen +Profiel wisselen is mislukt +Profiel maken is mislukt +Profielherstel vereist +Phoenix kan niet bevestigen welk profiel actief is. Probeer opnieuw voordat u doorgaat. +Profielherstel is nog niet beschikbaar +"%1$s" verwijderen? Trainingen, routines, records, badges, metingen en voortgang worden naar Default verplaatst. Dit kan niet ongedaan worden gemaakt. +Videogedrag +Oefeningsvideo's tonen +Toon oefendemonstraties; schakel dit uit op tragere apparaten +Gewichtsstap +Lichaamsgewicht +Duur setoverzicht +Aftellen voor automatisch starten +Routine automatisch starten +Gesproken herhalingsteller +Aftelpiepjes +Geluid na herhaling +Set starten door beweging +Gamificatie +Standaardbasis voor gewichtschaal +Startgewichten van routines +Stoppen bovenaan +Stildetectie +Drempel voor snelheidsverlies +Automatisch stoppen bij snelheidsverlies +VBT uitschakelen beïnvloedt alleen live trainingen; opgeslagen schattingen en metingen blijven beschikbaar. +``` + +- [ ] **Step 5: Add the complete German resource block** + +In `values-de/strings.xml`, replace the existing fallback-English `cd_led_scheme` declaration in place with the following line, then append the new block. Do not add a second declaration: + +```xml +LED-Farbschema +``` + +```xml +Profil +Profil +Profilwechsler öffnen +Profile +Profil wechseln +Übungsanalysen +Übung auswählen +Keine Übungshistorie für dieses Profil +Aktuelles 1RM +Geschwindigkeitsschätzung +Krafttest +Letzte abgeschlossene Einheit +Keine profilspezifische Schätzung +PR-Höhepunkte +Maximalgewicht +Geschätztes 1RM +Maximalvolumen +Letzter Verlauf +Gesamten Verlauf anzeigen +Einstellungen +Messwerte +Trainingsverhalten +LED +Geschwindigkeitsbasiertes Training +Sicherheit +VBT im Live-Training verwenden +Profil wird gewechselt… +Diese Übung ist nicht mehr verfügbar +Übungsanalysen konnten nicht geladen werden +Diese Profileinstellung konnte nicht gespeichert werden +Profilwechsel fehlgeschlagen +Profil konnte nicht erstellt werden +Profilwiederherstellung erforderlich +Phoenix konnte nicht bestätigen, welches Profil aktiv ist. Versuche es erneut, bevor du fortfährst. +Profilwiederherstellung ist weiterhin nicht verfügbar +„%1$s“ löschen? Trainings, Routinen, Rekorde, Abzeichen, Tests und Fortschrittsdaten werden zu Default verschoben. Dies kann nicht rückgängig gemacht werden. +Videoverhalten +Übungsvideos anzeigen +Übungsdemonstrationen anzeigen; auf langsameren Geräten deaktivieren +Gewichtsschritt +Körpergewicht +Dauer der Satzzusammenfassung +Autostart-Countdown +Routine automatisch starten +Gesprochener Wiederholungszähler +Countdown-Töne +Wiederholungston +Satzstart durch Bewegung +Gamification +Standardbasis für Gewichtsskalierung +Startgewichte für Routinen +Oben stoppen +Stillstandserkennung +Schwelle für Geschwindigkeitsverlust +Bei Geschwindigkeitsverlust automatisch beenden +Das Ausschalten von VBT betrifft nur Live-Trainings; gespeicherte Schätzungen und Tests bleiben verfügbar. +``` + +- [ ] **Step 6: Add the complete Spanish resource block** + +In `values-es/strings.xml`, replace the existing fallback-English `cd_led_scheme` declaration in place with the following line, then append the new block. Do not add a second declaration: + +```xml +Esquema de color LED +``` + +```xml +Perfil +Perfil +Abrir selector de perfiles +Perfiles +Cambiar perfil +Análisis del ejercicio +Elegir un ejercicio +No hay historial de ejercicios para este perfil +1RM actual +Estimación por velocidad +Evaluación de fuerza +Sesión completada reciente +No hay estimación para este perfil +Récords destacados +Peso máximo +1RM estimado +Volumen máximo +Historial reciente +Ver historial completo +Preferencias +Mediciones +Comportamiento del entrenamiento +LED +Entrenamiento basado en velocidad +Seguridad +Usar VBT durante entrenamientos en vivo +Cambiando perfil… +Este ejercicio ya no está disponible +No se pudo cargar el análisis del ejercicio +No se pudo guardar esta preferencia del perfil +No se pudo cambiar de perfil +No se pudo crear el perfil +Se requiere recuperar el perfil +Phoenix no pudo confirmar qué perfil está activo. Vuelve a intentarlo antes de continuar. +La recuperación del perfil sigue sin estar disponible +¿Eliminar "%1$s"? Sus entrenamientos, rutinas, récords, insignias, evaluaciones y datos de progreso pasarán a Default. Esta acción no se puede deshacer. +Comportamiento del vídeo +Mostrar vídeos de ejercicios +Mostrar demostraciones; desactívalo en dispositivos más lentos +Incremento de peso +Peso corporal +Duración del resumen de serie +Cuenta atrás de inicio automático +Iniciar rutina automáticamente +Contador de repeticiones por voz +Pitidos de cuenta atrás +Sonido al completar repetición +Iniciar serie con movimiento +Gamificación +Base predeterminada de escala de peso +Pesos iniciales de las rutinas +Detener arriba +Detección de bloqueo +Umbral de pérdida de velocidad +Finalizar al perder velocidad +Desactivar VBT solo afecta a los entrenamientos en vivo; las estimaciones y evaluaciones guardadas siguen disponibles. +``` + +- [ ] **Step 7: Add the complete French resource block** + +In `values-fr/strings.xml`, replace the existing fallback-English `cd_led_scheme` declaration in place with the following line, then append the new block. Do not add a second declaration: + +```xml +Palette de couleurs LED +``` + +```xml +Profil +Profil +Ouvrir le sélecteur de profil +Profils +Changer de profil +Analyse de l'exercice +Choisir un exercice +Aucun historique d'exercice pour ce profil +1RM actuel +Estimation par vélocité +Évaluation de force +Séance terminée récente +Aucune estimation propre à ce profil +Records marquants +Poids maximal +1RM estimé +Volume maximal +Historique récent +Voir tout l'historique +Préférences +Mesures +Comportement d'entraînement +LED +Entraînement basé sur la vélocité +Sécurité +Utiliser le VBT pendant les entraînements en direct +Changement de profil… +Cet exercice n'est plus disponible +Impossible de charger l'analyse de l'exercice +Impossible d'enregistrer cette préférence du profil +Impossible de changer de profil +Impossible de créer le profil +Récupération du profil requise +Phoenix n’a pas pu confirmer quel profil est actif. Réessayez avant de continuer. +La récupération du profil est toujours indisponible +Supprimer « %1$s » ? Ses entraînements, routines, records, badges, évaluations et données de progression seront transférés vers Default. Cette action est irréversible. +Comportement vidéo +Afficher les vidéos d'exercice +Afficher les démonstrations ; désactivez-les sur les appareils plus lents +Incrément de poids +Poids corporel +Durée du résumé de série +Compte à rebours automatique +Démarrer la routine automatiquement +Compteur vocal de répétitions +Bips du compte à rebours +Son de fin de répétition +Démarrer la série par mouvement +Ludification +Base de mise à l'échelle du poids +Poids de départ des routines +Arrêt en haut +Détection de blocage +Seuil de perte de vélocité +Arrêt automatique sur perte de vélocité +Désactiver le VBT ne concerne que les entraînements en direct ; les estimations et évaluations enregistrées restent disponibles. +``` + +- [ ] **Step 8: Run resource generation and the locale contract** + +Run: + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:generateResourceAccessorsForCommonMain :shared:testAndroidHostTest --tests "*ProfileResourceContractTest*" :shared:compileAndroidMain :shared:compileKotlinIosArm64 :shared:compileTestKotlinIosArm64 --console=plain +``` + +Expected: BUILD SUCCESSFUL. Compose resource generation parses all five edited XML files and emits the common accessors; the Android-host contract executes all three tests, including shared-relative path discovery; Android main and iOS Arm64 main compile those accessors; iOS Arm64 test compilation verifies the common contract against `SourceFileReader.ios.kt`. This repository has no runnable iOS simulator test task, so do not claim an iOS runtime test from `compileTestKotlinIosArm64`. + +- [ ] **Step 9: Commit the resource contract** + +```powershell +$expected = @( + 'shared/src/commonMain/composeResources/values/strings.xml' + 'shared/src/commonMain/composeResources/values-nl/strings.xml' + 'shared/src/commonMain/composeResources/values-de/strings.xml' + 'shared/src/commonMain/composeResources/values-es/strings.xml' + 'shared/src/commonMain/composeResources/values-fr/strings.xml' + 'shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ProfileResourceContractTest.kt' +) | Sort-Object +$actual = @(git diff --name-only | Sort-Object) +$scopeDiff = Compare-Object -ReferenceObject $expected -DifferenceObject $actual +if ($scopeDiff) { + $scopeDiff | Format-Table -AutoSize + throw 'Task 3 worktree scope differs from the six-file resource contract' +} +git diff --check -- $expected +if ($LASTEXITCODE -ne 0) { throw 'Task 3 diff check failed' } +git add -- $expected +$staged = @(git diff --cached --name-only | Sort-Object) +$stagedDiff = Compare-Object -ReferenceObject $expected -DifferenceObject $staged +if ($stagedDiff) { + $stagedDiff | Format-Table -AutoSize + throw 'Task 3 staged scope differs from the six-file resource contract' +} +git diff --cached --check +if ($LASTEXITCODE -ne 0) { throw 'Task 3 cached diff check failed' } +git commit -m "feat: add localized profile screen copy" +``` + +--- + +### Task 4: Extract Reusable Profile Identity UI and Build the Switcher Sheet + +**Files:** +- Create: `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileIdentityComponents.kt` +- Create: `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileDialogs.kt` +- Create: `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileSwitcherSheet.kt` +- Create: `shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/components/ProfileIdentityPolicyTest.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileListItem.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileSpeedDial.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/util/TestTags.kt` + +**Interfaces:** +- Consumes: existing `UserProfile`; Task 3's localized profile/color resources; and common Material 3 sheet, dialog, selection, and semantics primitives. +- Produces: `ProfileColors`, `PROFILE_COLOR_COUNT`, `suggestedProfileColorIndex`, `normalizedProfileColorIndex`, contrast-safe `profileInitialsColor`, active-only `canDeleteProfile`, `ProfileAvatar`, callback-only add/edit/delete dialogs, `canDismissProfileSwitcher`, and a switch/create-only `ProfileSwitcherSheet` for Tasks 6–7. +- Interaction contract: every clickable avatar/swatch is at least 48dp, profile choices expose radio/selected semantics, dialog callbacks never mutate repositories or close optimistically, and a switch in flight cannot hide the sheet through drag, back, scrim, or callback dismissal. +- Delete-copy invariant: Task 6 deletes only the active non-default profile, whose repository reassignment target is Default. The shared policy and dialog must encode that active-only precondition so `profile_delete_reassign_message` can never lie about the target. + +- [ ] **Step 1: Write the failing identity policy test** + +```kotlin +package com.devil.phoenixproject.presentation.components + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import com.devil.phoenixproject.data.repository.UserProfile +import com.devil.phoenixproject.testutil.readProjectFile +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class ProfileIdentityPolicyTest { + @Test + fun `palette selection wraps and invalid stored indexes normalize`() { + assertEquals(PROFILE_COLOR_COUNT, ProfileColors.size) + assertEquals(0, suggestedProfileColorIndex(-1)) + assertEquals(0, suggestedProfileColorIndex(0)) + assertEquals(7, suggestedProfileColorIndex(7)) + assertEquals(0, suggestedProfileColorIndex(8)) + + assertEquals(0, normalizedProfileColorIndex(-1)) + assertEquals(0, normalizedProfileColorIndex(0)) + assertEquals(7, normalizedProfileColorIndex(7)) + assertEquals(0, normalizedProfileColorIndex(8)) + } + + @Test + fun `avatar initials meet text contrast across the shared palette`() { + ProfileColors.forEach { background -> + val foreground = profileInitialsColor(background) + assertTrue( + contrastRatio(foreground, background) >= 4.5f, + "Insufficient initials contrast for $background", + ) + } + } + + @Test + fun `only the active non-default profile may be deleted`() { + assertFalse(canDeleteProfile(profile("default", isActive = true))) + assertFalse(canDeleteProfile(profile("athlete-a", isActive = false))) + assertTrue(canDeleteProfile(profile("athlete-a", isActive = true))) + } + + @Test + fun `switcher may dismiss only while no switch is in flight`() { + assertTrue(canDismissProfileSwitcher(null)) + assertFalse(canDismissProfileSwitcher("athlete-a")) + } + + @Test + fun `identity dialogs are callback only guarded and responsive`() { + val dialogs = assertNotNull( + readProjectFile( + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileDialogs.kt", + ), + ) + + assertFalse(dialogs.contains("UserProfileRepository")) + assertFalse(dialogs.contains("CoroutineScope")) + assertFalse(dialogs.contains("DestructiveConfirmDialog(")) + assertContains(dialogs, "fun ProfileAddDialog(") + assertContains(dialogs, "fun ProfileEditDialog(") + assertContains(dialogs, "fun ProfileDeleteDialog(") + assertContains(dialogs, "require(canDeleteProfile(profile))") + assertContains(dialogs, "AlertDialog(") + assertContains(dialogs, "name.trim()") + assertContains(dialogs, "normalizedProfileColorIndex(selectedColorIndex)") + assertContains(dialogs, "ProfileColors.indices.chunked(4)") + assertContains(dialogs, ".size(48.dp)") + assertContains(dialogs, ".selectable(") + assertContains(dialogs, "Role.RadioButton") + assertContains(dialogs, ".selectableGroup()") + assertContains(dialogs, "enabled = !isSubmitting") + assertContains(dialogs, "if (!isSubmitting)") + val deleteCopyOffset = dialogs.indexOf("Res.string.profile_delete_reassign_message") + assertTrue(deleteCopyOffset >= 0) + val deleteCopyCall = dialogs.substring( + deleteCopyOffset, + minOf(dialogs.length, deleteCopyOffset + 240), + ) + assertContains(deleteCopyCall, "profile.name") + } + + @Test + fun `switcher is switch create only and cannot hide while switching`() { + val switcher = assertNotNull( + readProjectFile( + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileSwitcherSheet.kt", + ), + ) + + assertContains(switcher, "Res.string.profiles_title") + assertContains(switcher, "TestTags.PROFILE_SWITCHER_SHEET") + assertContains(switcher, "TestTags.ACTION_ADD_PROFILE") + assertFalse(switcher.contains("onEditProfile")) + assertFalse(switcher.contains("onDeleteProfile")) + assertFalse(switcher.contains("combinedClickable")) + assertContains(switcher, "rememberUpdatedState") + assertContains(switcher, "confirmValueChange") + assertContains(switcher, "targetValue != SheetValue.Hidden") + assertContains(switcher, "sheetGesturesEnabled = canDismiss") + assertContains(switcher, "if (canDismissProfileSwitcher(") + assertContains(switcher, ".selectableGroup()") + } + + @Test + fun `identity surfaces declare accessible semantics and all downstream tags`() { + val identity = assertNotNull( + readProjectFile( + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileIdentityComponents.kt", + ), + ) + val row = assertNotNull( + readProjectFile( + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileListItem.kt", + ), + ) + val tags = assertNotNull( + readProjectFile( + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/util/TestTags.kt", + ), + ) + + assertContains(identity, "minimumInteractiveComponentSize") + assertContains(identity, "Role.Button") + assertContains(identity, "contentDescription = accessibleName") + assertContains(identity, "clearAndSetSemantics") + assertContains(row, "heightIn(min = 56.dp)") + assertContains(row, "Modifier.selectable(") + assertContains(row, "Role.RadioButton") + assertContains(row, "selected = isActive") + assertContains(row, "enabled && !switching") + listOf( + "PROFILE_SWITCHER_SHEET", + "ACTION_ADD_PROFILE", + "ACTION_EDIT_PROFILE", + "ACTION_DELETE_PROFILE", + ).forEach { tag -> assertContains(tags, "const val $tag") } + } + + private fun profile(id: String, isActive: Boolean) = UserProfile( + id = id, + name = id, + colorIndex = 0, + createdAt = 1L, + isActive = isActive, + ) + + private fun contrastRatio(first: Color, second: Color): Float { + val lighter = maxOf(first.luminance(), second.luminance()) + val darker = minOf(first.luminance(), second.luminance()) + return (lighter + 0.05f) / (darker + 0.05f) + } +} +``` + +- [ ] **Step 2: Run the policy test and confirm the red state** + +Run: + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest --tests "*ProfileIdentityPolicyTest*" --console=plain +``` + +Expected: FAIL to compile because the extracted policy functions/files and the four downstream test tags do not exist. + +- [ ] **Step 3: Extract the palette, avatar, and identity policy before deleting the speed dial** + +Create `ProfileIdentityComponents.kt` with the palette currently defined in `ProfileSpeedDial.kt`. Keep palette indexing total for imported/synced legacy rows, encode the active-only delete invariant, and choose whichever of black/white gives the stronger WCAG contrast against the avatar background: + +```kotlin +val ProfileColors = listOf( + Color(0xFF3B82F6), Color(0xFF10B981), Color(0xFFF59E0B), Color(0xFFEF4444), + Color(0xFF8B5CF6), Color(0xFFEC4899), Color(0xFF06B6D4), Color(0xFFF97316), +) + +const val PROFILE_COLOR_COUNT = 8 + +internal fun suggestedProfileColorIndex(profileCount: Int): Int = + profileCount.coerceAtLeast(0) % PROFILE_COLOR_COUNT + +internal fun normalizedProfileColorIndex(colorIndex: Int): Int = + colorIndex.takeIf(ProfileColors.indices::contains) ?: 0 + +internal fun canDeleteProfile(profile: UserProfile): Boolean = + profile.id != "default" && profile.isActive + +internal fun profileInitialsColor(background: Color): Color { + val luminance = background.luminance() + val blackContrast = (luminance + 0.05f) / 0.05f + val whiteContrast = 1.05f / (luminance + 0.05f) + return if (blackContrast >= whiteContrast) Color.Black else Color.White +} + +@Composable +fun ProfileAvatar( + profile: UserProfile, + isActive: Boolean = false, + onClick: (() -> Unit)? = null, + modifier: Modifier = Modifier, + size: Dp = 40.dp, +) { + val accessibleName = profile.name.trim().ifEmpty { "?" } + val interactionModifier = if (onClick == null) { + // The surrounding row/header owns the name; do not announce a duplicate initial. + Modifier.clearAndSetSemantics { } + } else { + Modifier + .minimumInteractiveComponentSize() + .clip(CircleShape) + .clickable(onClick = onClick) + .clearAndSetSemantics { + contentDescription = accessibleName + role = Role.Button + onClick(label = accessibleName) { + onClick() + true + } + } + } + val background = ProfileColors[normalizedProfileColorIndex(profile.colorIndex)] + + Box( + modifier = modifier.then(interactionModifier), + contentAlignment = Alignment.Center, + ) { + Surface( + modifier = Modifier + .size(size) + .shadow(if (isActive) 8.dp else 0.dp, CircleShape), + shape = CircleShape, + color = background, + ) { + Text( + text = accessibleName.take(1).uppercase(), + color = profileInitialsColor(background), + fontWeight = FontWeight.Bold, + modifier = Modifier.wrapContentSize(Alignment.Center), + ) + } + } +} +``` + +Use `androidx.compose.ui.graphics.luminance`, `minimumInteractiveComponentSize`, `Role.Button`, and `clearAndSetSemantics` from common Compose APIs. The visual circle stays 40dp by default, while a clickable avatar's outer hit target is at least 48dp. The contrast test is the guard against future palette changes. + +Remove only the duplicate palette/constants/avatar from `ProfileSpeedDial.kt`; its named `ProfileAvatar` call remains source-compatible. Do not edit `RoutinesTab`: it already imports the package-level `ProfileColors` symbol, and Kotlin imports do not point at source files. + +- [ ] **Step 4: Make the list row switcher-specific** + +Extend `ProfileListItem` with switcher state while keeping its legacy side-panel call source-compatible until Task 7's atomic selector replacement: + +```kotlin +@Composable +fun ProfileListItem( + profile: UserProfile, + isActive: Boolean, + onClick: () -> Unit, + onLongClick: (() -> Unit)? = null, + enabled: Boolean = true, + switching: Boolean = false, + modifier: Modifier = Modifier, +) +``` + +Use a full-width 56dp-minimum row, a non-clickable/decorative `ProfileAvatar`, the profile name with one-line ellipsis, a check icon for `isActive`, and a 20dp `CircularProgressIndicator` for `switching`. Disable every pointer action while `switching` is true. + +The legacy side-panel branch keeps `combinedClickable` because active rows still need their edit/delete context action. Add explicit radio/selected semantics there. The new switcher branch uses `selectable`, which supplies the correct mutually-exclusive control semantics and disables the already-active row: + +```kotlin +val interactionEnabled = enabled && !switching +val interactionModifier = if (onLongClick != null) { + Modifier + .combinedClickable( + enabled = interactionEnabled, + onClick = onClick, + onLongClick = onLongClick, + ) + .semantics { + selected = isActive + role = Role.RadioButton + } +} else { + Modifier.selectable( + selected = isActive, + enabled = interactionEnabled && !isActive, + role = Role.RadioButton, + onClick = onClick, + ) +} + +Surface( + modifier = modifier + .fillMaxWidth() + .heightIn(min = 56.dp) + .then(interactionModifier), +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + ProfileAvatar(profile = profile, isActive = isActive) + Text( + text = profile.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + when { + switching -> CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + ) + isActive -> Icon( + imageVector = Icons.Default.Check, + contentDescription = stringResource(Res.string.cd_active), + modifier = Modifier.size(20.dp), + ) + } + } +} +``` + +Task 7 removes the nullable legacy callback and `combinedClickable` while atomically deleting the last side-panel call. + +- [ ] **Step 5: Create callback-only dialogs that never mutate repositories** + +Use these exact public signatures in `ProfileDialogs.kt`: + +```kotlin +@Composable +fun ProfileAddDialog( + existingProfileCount: Int, + isSubmitting: Boolean, + onConfirm: (name: String, colorIndex: Int) -> Unit, + onDismiss: () -> Unit, +) + +@Composable +fun ProfileEditDialog( + profile: UserProfile, + isSubmitting: Boolean, + onConfirm: (name: String, colorIndex: Int) -> Unit, + onDismiss: () -> Unit, +) + +@Composable +fun ProfileDeleteDialog( + profile: UserProfile, + isSubmitting: Boolean, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) +``` + +Each add/edit dialog owns only transient text/color selection. Key edit state by `profile.id`, normalize the stored color before displaying or confirming it, trim the name passed to `onConfirm`, disable the text field, swatches, confirmation, cancellation, and `onDismissRequest` while submitting, and never call `onDismiss` from a confirmation callback. Keep these exact guards in the shared `AlertDialog` implementation: + +```kotlin +val trimmedName = name.trim() +AlertDialog( + onDismissRequest = { if (!isSubmitting) onDismiss() }, + confirmButton = { + TextButton( + onClick = onConfirm, + enabled = trimmedName.isNotEmpty() && !isSubmitting, + ) { Text(confirmLabel) } + }, + dismissButton = { + TextButton( + onClick = onDismiss, + enabled = !isSubmitting, + ) { Text(stringResource(Res.string.action_cancel)) } + }, +) +``` + +Build `ProfileDeleteDialog` with its own guarded `AlertDialog`; do not reuse `DestructiveConfirmDialog`, whose current API cannot disable its buttons. Require the active non-default invariant before rendering, keep the dialog open until Task 6 observes a success event, and bind the one `%1$s` placeholder: + +```kotlin +require(canDeleteProfile(profile)) { + "Only the active non-default profile may be deleted from Profile" +} +AlertDialog( + onDismissRequest = { if (!isSubmitting) onDismiss() }, + title = { Text(stringResource(Res.string.delete_profile)) }, + text = { + Text( + text = stringResource( + Res.string.profile_delete_reassign_message, + profile.name, + ), + ) + }, + confirmButton = { + TextButton(onClick = onConfirm, enabled = !isSubmitting) { + Text(stringResource(Res.string.action_delete)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss, enabled = !isSubmitting) { + Text(stringResource(Res.string.action_cancel)) + } + }, +) +``` + +Render the eight translated color choices as exactly two rows of four. A single eight-item row cannot preserve 48dp targets on compact dialog widths. The 48dp wrapper is the radio target; the 36dp inner circle is visual only. Use the same contrast-safe foreground for the selected border/check: + +```kotlin +Column( + modifier = Modifier.fillMaxWidth().selectableGroup(), + verticalArrangement = Arrangement.spacedBy(4.dp), +) { + ProfileColors.indices.chunked(4).forEach { indices -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + indices.forEach { index -> + val color = ProfileColors[index] + val selected = normalizedProfileColorIndex(selectedColorIndex) == index + val description = stringResource( + Res.string.cd_select_profile_color, + colorNames[index], + ) + Box( + modifier = Modifier + .size(48.dp) + .selectable( + selected = selected, + enabled = !isSubmitting, + role = Role.RadioButton, + onClick = { onColorSelected(index) }, + ) + .semantics { contentDescription = description }, + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .size(36.dp) + .background(color, CircleShape) + .then( + if (selected) { + Modifier.border( + 2.dp, + profileInitialsColor(color), + CircleShape, + ) + } else { + Modifier + }, + ), + contentAlignment = Alignment.Center, + ) { + if (selected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = profileInitialsColor(color), + modifier = Modifier.size(20.dp), + ) + } + } + } + } + } + } +} +``` + +The `Profile*Dialog` names intentionally avoid colliding with the repository-coupled legacy dialogs until Task 7 deletes them atomically. + +- [ ] **Step 6: Build the shared switch/create-only sheet** + +```kotlin +internal fun canDismissProfileSwitcher(switchingTargetProfileId: String?): Boolean = + switchingTargetProfileId == null + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProfileSwitcherSheet( + profiles: List, + activeProfileId: String?, + switchingTargetProfileId: String?, + onSelectProfile: (UserProfile) -> Unit, + onAddProfile: () -> Unit, + onDismiss: () -> Unit, +) { + val currentSwitchingTargetProfileId by rememberUpdatedState(switchingTargetProfileId) + val canDismiss = canDismissProfileSwitcher(switchingTargetProfileId) + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true, + confirmValueChange = { targetValue -> + targetValue != SheetValue.Hidden || + canDismissProfileSwitcher(currentSwitchingTargetProfileId) + }, + ) + + ModalBottomSheet( + onDismissRequest = { + if (canDismissProfileSwitcher(currentSwitchingTargetProfileId)) onDismiss() + }, + sheetState = sheetState, + sheetGesturesEnabled = canDismiss, + modifier = Modifier.testTag(TestTags.PROFILE_SWITCHER_SHEET), + ) { + Text( + text = stringResource(Res.string.profiles_title), + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), + ) + LazyColumn( + modifier = Modifier.fillMaxWidth().selectableGroup(), + contentPadding = PaddingValues(bottom = 24.dp), + ) { + items(profiles, key = UserProfile::id) { profile -> + ProfileListItem( + profile = profile, + isActive = profile.id == activeProfileId, + enabled = canDismiss, + switching = profile.id == switchingTargetProfileId, + onClick = { onSelectProfile(profile) }, + ) + } + item { + ListItem( + headlineContent = { Text(stringResource(Res.string.add_profile)) }, + leadingContent = { Icon(Icons.Default.Add, contentDescription = null) }, + modifier = Modifier + .fillMaxWidth() + .clickable( + enabled = canDismiss, + role = Role.Button, + onClick = onAddProfile, + ) + .testTag(TestTags.ACTION_ADD_PROFILE), + ) + } + } + } +} +``` + +The state veto is required because Material 3 hides a modal sheet before delivering `onDismissRequest`; a callback-only guard would otherwise leave a remembered but hidden sheet after a failed switch. The veto covers back/scrim dismissal, and `sheetGesturesEnabled` prevents a misleading drag while the switch is in flight. + +Add all four stable tags to `TestTags`: + +```kotlin +const val PROFILE_SWITCHER_SHEET = "profile-switcher-sheet" +const val ACTION_ADD_PROFILE = "action-add-profile" +const val ACTION_EDIT_PROFILE = "action-edit-profile" +const val ACTION_DELETE_PROFILE = "action-delete-profile" +``` + +`PROFILE_SWITCHER_SHEET` belongs to the sheet root and `ACTION_ADD_PROFILE` belongs to its add row. `ACTION_EDIT_PROFILE` and `ACTION_DELETE_PROFILE` are declared now for Task 6's header actions. Do not put the same action tag on dialog confirmation buttons while the underlying action remains composed, because duplicate visible tags make UI tests ambiguous. The sheet deliberately has no edit/delete callbacks. + +- [ ] **Step 7: Run focused tests and compile both KMP targets** + +Run: + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest --tests "*ProfileIdentityPolicyTest*" :shared:compileAndroidMain :shared:compileKotlinIosArm64 :shared:compileTestKotlinIosArm64 --rerun --console=plain +``` + +Expected: BUILD SUCCESSFUL; all seven identity policy/source-contract cases execute, production composables compile for Android and iOS, and the common test plus `SourceFileReader.ios.kt` compile for iOS. This is an iOS compile check, not an iOS runtime test. + +Verify the exact Android-host test count and the callback-only boundary: + +```powershell +$result = [xml](Get-Content 'shared/build/test-results/testAndroidHostTest/TEST-com.devil.phoenixproject.presentation.components.ProfileIdentityPolicyTest.xml' -Raw) +$cases = @($result.SelectNodes("//testcase[@classname='com.devil.phoenixproject.presentation.components.ProfileIdentityPolicyTest']")) +if ($cases.Count -ne 7) { throw "Expected 7 ProfileIdentityPolicyTest cases, found $($cases.Count)" } +if (@($result.SelectNodes('//failure | //error')).Count -ne 0) { throw 'ProfileIdentityPolicyTest failed' } + +$repositoryCoupling = rg -n 'UserProfileRepository|CoroutineScope|profileRepository\.|scope\.launch' shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileDialogs.kt +if ($LASTEXITCODE -eq 0) { + $repositoryCoupling + throw 'Callback-only profile dialogs still mutate repositories or launch work' +} + +git diff --check -- shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileIdentityComponents.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileDialogs.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileSwitcherSheet.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileListItem.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileSpeedDial.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/util/TestTags.kt shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/components/ProfileIdentityPolicyTest.kt +if ($LASTEXITCODE -ne 0) { throw 'Task 4 diff check failed' } +``` + +- [ ] **Step 8: Commit the reusable identity surface** + +```powershell +git add shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileIdentityComponents.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileDialogs.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileSwitcherSheet.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileListItem.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileSpeedDial.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/util/TestTags.kt shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/components/ProfileIdentityPolicyTest.kt +git commit -m "refactor: extract profile identity components" +``` + +--- + +### Task 5: Add the Route-Scoped Profile ViewModel and Profile-Correct Insights State + +**Files:** +- Create: `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileViewModel.kt` +- Create: `shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileViewModelTest.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/di/PresentationModule.kt` +- Modify: `shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeUserProfileRepository.kt` +- Modify: `shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakePersonalRecordRepository.kt` + +**Interfaces:** +- Consumes: `ActiveProfileContext`, `ExerciseRepository`, Task 2's bounded history methods and + resolver, `PersonalRecordRepository.getAllPRsForExercise`, and the constructor-stable + `ExternalMeasurementRepository` reserved for Task 8. +- Produces: one route-scoped `ProfileUiState`, per-profile in-memory selection restoration, + recoverable `selectionFailure`, three independently loadable insight blocks, same-profile Ready + refresh without data reset, and typed UI events used by Tasks 6 and 8. + +- [ ] **Step 1: Add current-code-compatible deterministic controls to the existing fakes** + +`FakeUserProfileRepository` currently owns `profiles`, `preferenceFlows`, `localSafety`, +`ensurePreferenceFlow`, `setActiveIdentityLocked`, and `publishReady`; it does **not** own +`preferencesByProfile` or `localSafetyByProfile`. Add the helpers below using those exact current +members. Replace the body of `createProfileLocked` with the delegating form so production-like fake +creation and UI-test seeding share the same preference construction and identity-flow updates: + +```kotlin +fun seedReadyProfileForTest( + profileId: String, + name: String = profileId, + colorIndex: Int = 0, +): UserProfile { + seedProfileWithDefaultPreferences(profileId, name, colorIndex) + emitReadyForTest(profileId) + return profiles.getValue(profileId) +} + +fun emitSwitchingForTest(targetProfileId: String?) { + _activeProfileContext.value = ActiveProfileContext.Switching(targetProfileId) +} + +fun emitReadyForTest(profileId: String) { + require(profiles.containsKey(profileId)) { "Unknown profile: $profileId" } + setActiveIdentityLocked(profileId) + publishReady(profileId) +} + +private fun seedProfileWithDefaultPreferences( + profileId: String, + name: String, + colorIndex: Int, +): UserProfile { + require(profileId.isNotBlank()) { "Profile ID must not be blank" } + val trimmedName = name.trim() + require(trimmedName.isNotEmpty()) { "Profile name must not be blank" } + require(!profiles.containsKey(profileId)) { "Profile already exists: $profileId" } + val profile = UserProfile( + id = profileId, + name = trimmedName, + colorIndex = colorIndex, + createdAt = currentTimeMillis(), + isActive = false, + ) + profiles[profileId] = profile + ensurePreferenceFlow(profileId) + localSafety.getOrPut(profileId) { ProfileLocalSafetyPreferences() } + updateIdentityFlows() + return profile +} + +private fun createProfileLocked( + name: String, + colorIndex: Int, + id: String = generateUUID(), +): UserProfile = seedProfileWithDefaultPreferences( + profileId = id, + name = name, + colorIndex = colorIndex, +) +``` + +`emitReadyForTest` must update the identity map before publishing Ready. A test context whose +`Ready.profile.isActive` is false while `activeProfile` points elsewhere is not acceptable fake +parity. + +Add a request record, an ordinary/cancellation failure seam, and a suspend hook to +`FakePersonalRecordRepository`. The hook runs after a profile-filtered snapshot is captured and +allows tests to hold or deliberately make one read non-cooperative without changing the production +interface: + +```kotlin +data class GetAllForExerciseRequest( + val exerciseId: String, + val profileId: String, +) + +val getAllForExerciseRequests = mutableListOf() +var getAllForExerciseFailure: Throwable? = null +var beforeGetAllForExerciseReturn: (suspend (String, String) -> Unit)? = null + +fun reset() { + records.clear() + updateCalls.clear() + getAllForExerciseRequests.clear() + getAllForExerciseFailure = null + beforeGetAllForExerciseReturn = null + updateRecordsFlow() +} + +override suspend fun getAllPRsForExercise( + exerciseId: String, + profileId: String, +): List { + getAllForExerciseRequests += GetAllForExerciseRequest(exerciseId, profileId) + getAllForExerciseFailure?.let { throw it } + val result = records.values + .filter { it.exerciseId == exerciseId && it.profileId == profileId } + .sortedByDescending { it.timestamp } + beforeGetAllForExerciseReturn?.invoke(exerciseId, profileId) + return result +} +``` + +Failure is checked before reading records. `reset()` must clear every new control so existing fake +users retain the documented clean-reset behavior. + +- [ ] **Step 2: Write the complete failing lifecycle, isolation, and partial-failure suite** + +Use `TestCoroutineRule`, `runTest`, and fresh fakes per test. Construct the real Task 2 resolver so +the ViewModel test covers its actual repository calls: + +```kotlin +@get:Rule +val coroutineRule = TestCoroutineRule() + +private lateinit var profiles: FakeUserProfileRepository +private lateinit var exercises: FakeExerciseRepository +private lateinit var workouts: FakeWorkoutRepository +private lateinit var personalRecords: FakePersonalRecordRepository +private lateinit var velocity: FakeVelocityOneRepMaxRepository +private lateinit var assessments: FakeAssessmentRepository +private lateinit var externalMeasurements: FakeExternalMeasurementRepository + +@Before +fun setUp() { + profiles = FakeUserProfileRepository() + exercises = FakeExerciseRepository() + workouts = FakeWorkoutRepository() + personalRecords = FakePersonalRecordRepository() + velocity = FakeVelocityOneRepMaxRepository() + assessments = FakeAssessmentRepository() + externalMeasurements = FakeExternalMeasurementRepository() +} + +private fun createViewModel() = ProfileViewModel( + profiles = profiles, + exercises = exercises, + workouts = workouts, + personalRecords = personalRecords, + resolveCurrentOneRepMax = ResolveCurrentOneRepMaxUseCase( + velocityRepository = velocity, + assessmentRepository = assessments, + workoutRepository = workouts, + ), + externalMeasurements = externalMeasurements, +) + +private fun session( + id: String, + profileId: String, + exerciseId: String, + timestamp: Long, + weightPerCableKg: Float = 40f, + workingReps: Int = 5, +) = WorkoutSession( + id = id, + profileId = profileId, + exerciseId = exerciseId, + exerciseName = exerciseId, + timestamp = timestamp, + weightPerCableKg = weightPerCableKg, + workingReps = workingReps, + totalReps = workingReps, +) + +private fun record( + id: Long, + profileId: String, + exerciseId: String, + prType: PRType, + weightPerCableKg: Float, + oneRepMax: Float, + volume: Float, +) = PersonalRecord( + id = id, + profileId = profileId, + exerciseId = exerciseId, + exerciseName = exerciseId, + weightPerCableKg = weightPerCableKg, + reps = 5, + oneRepMax = oneRepMax, + timestamp = id, + workoutMode = "Old School", + prType = prType, + volume = volume, +) +``` + +The core test must drain the `StandardTestDispatcher` after **every** Switching emission. Emitting +Switching and Ready back-to-back can be conflated by `StateFlow` and does not prove stale-state +clearing: + +```kotlin +@Test +fun `A to B to A restores selection and Switching clears all visible profile data`() = runTest { + val bench = Exercise(name = "Bench", muscleGroup = "Chest", id = "bench") + val squat = Exercise(name = "Squat", muscleGroup = "Legs", id = "squat") + exercises.addExercise(bench) + exercises.addExercise(squat) + profiles.seedReadyProfileForTest("a", name = "A") + profiles.seedReadyProfileForTest("b", name = "B") + val viewModel = createViewModel() + runCurrent() + + profiles.emitSwitchingForTest("a") + runCurrent() + profiles.emitReadyForTest("a") + advanceUntilIdle() + viewModel.selectExercise(bench) + advanceUntilIdle() + + profiles.emitSwitchingForTest("b") + runCurrent() + val switching = viewModel.uiState.value + assertIs(switching.context) + assertNull(switching.selectedExercise) + assertNull(switching.missingExerciseId) + assertNull(switching.selectionFailure) + assertIs(switching.currentOneRepMax) + assertIs(switching.prHighlights) + assertIs(switching.recentSessions) + + profiles.emitReadyForTest("b") + advanceUntilIdle() + viewModel.selectExercise(squat) + advanceUntilIdle() + + profiles.emitSwitchingForTest("a") + runCurrent() + profiles.emitReadyForTest("a") + advanceUntilIdle() + assertEquals("bench", viewModel.uiState.value.selectedExercise?.id) +} +``` + +Add exactly these thirteen additional tests, for fourteen Task 5 tests total: + +| Test name | Exact setup and assertions | +|---|---| +| `no saved selection uses only the Ready profile recent exercise` | Add newer B/Squat and older A/Bench completed sessions, clear `recentCompletedRequests` immediately before entering A, and assert Bench is selected. Assert every resulting request has `profileId == "a"`, `exerciseId == "bench"`, and `limit == MAX_RECENT_EXERCISE_SESSIONS`. | +| `deleted saved exercise reports the unresolved profile fallback id` | Select Bench for A, switch to B with a dispatcher drain, call `exercises.reset()`, retain an A/Bench completed session, return to A, and assert `selectedExercise == null`, `missingExerciseId == "bench"`, and `selectionFailure == null`. | +| `selection fallback failure is distinct from missing and the collector recovers` | Set `mostRecentCompletedExerciseFailure = IllegalStateException("fallback")`, enter A, and assert `selectionFailure` is that failure while `missingExerciseId == null`. Clear the failure, emit Switching and drain, emit Ready, and assert fallback selection loads; this proves the long-lived collector survived. | +| `null resolver and empty repositories use explicit empty contracts` | Select a valid exercise with no estimates, PRs, or sessions. Assert 1RM is `Empty`, PRs are `Ready(ProfilePrHighlights(null, null, null))`, and sessions are `Ready(emptyList())`. | +| `PR failure does not blank one rep max or recent sessions` | Seed one valid A/Bench session, set `getAllForExerciseFailure = IllegalStateException("prs")`, select Bench, and assert PR is `Failed` while 1RM and sessions are `Ready`. | +| `resolver failure does not blank PRs or recent sessions` | Seed one valid A/Bench session, set `velocity.latestPassingFailure = IllegalStateException("1rm")`, select Bench, and assert only 1RM is `Failed`; PRs and sessions are `Ready`. | +| `recent session failure does not blank an earlier source one rep max or PRs` | Save a valid A/Bench assessment before selecting, set `recentCompletedFailure = IllegalStateException("recent")`, and assert assessment-backed 1RM and PRs are `Ready` while sessions are `Failed`. Seeding the assessment is required because the resolver otherwise uses the same failing history API. | +| `mixed A and B records sessions and selections never cross profiles` | Seed different A/B sessions and PR maxima for the same exercise, load A then B with a drained Switching state, and assert each state contains only its profile's timestamps and PR values. Assert `getAllForExerciseRequests` contains the captured profile ID for every publication. | +| `same profile Ready refresh updates context without restarting insights` | Load A fully, capture all three loadables plus `workouts.recentCompletedRequests.size` and `personalRecords.getAllForExerciseRequests.size`, call `profiles.updateCore("a", ready.preferences.core.value.copy(bodyWeightKg = 82f))`, and assert the context contains 82 kg while selection, loadables, and both counts are unchanged. | +| `nullable blank and switching time selections are ignored` | Pass exercises with null and blank IDs, then emit Switching without draining and call `selectExercise` again. Assert no new insight requests occur and B has no saved selection when Ready arrives. This proves the method checks repository context, not only lagging UI state. | +| `cooperative insight cancellation is never converted to Failed` | Suspend the A PR hook with `awaitCancellation()`, emit Switching and drain, and assert all three branches are `Empty` in the Switching state with no `Failed` value. | +| `non cooperative slow A result cannot overwrite B` | Use the suspend hook shown below, switch to B after A starts, and assert B's PR values remain after the canceled A call deliberately returns. | +| `non finite and non positive PR values are excluded per field` | Add NaN, infinity, zero, negative, and valid values across both PR types. Assert each highlight contains only the maximum finite positive value for its own mapping. | + +Use this exact non-cooperative hook in the stale-result test. It swallows cancellation only inside +the test seam so the A repository call returns and exercises the publication guard: + +```kotlin +val aStarted = CompletableDeferred() +val staleAReturned = CompletableDeferred() +personalRecords.beforeGetAllForExerciseReturn = { _, profileId -> + if (profileId == "a") { + aStarted.complete(Unit) + try { + awaitCancellation() + } catch (_: CancellationException) { + // Deliberately non-cooperative test double: return a stale A snapshot. + } + staleAReturned.complete(Unit) + } +} + +profiles.emitReadyForTest("a") +advanceUntilIdle() +viewModel.selectExercise(bench) +aStarted.await() +profiles.emitSwitchingForTest("b") +runCurrent() +profiles.emitReadyForTest("b") +advanceUntilIdle() +staleAReturned.await() +assertEquals(expectedBHighlights, assertIs>( + viewModel.uiState.value.prHighlights, +).value) +``` + +Every `PersonalRecord` and `WorkoutSession` fixture in these tests must set `profileId` explicitly; +their domain defaults are `"default"` and would make an A/B isolation test a false positive. + +- [ ] **Step 3: Run the ViewModel tests and confirm the red state** + +Run: + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest --tests "*ProfileViewModelTest*" --console=plain +``` + +Expected: FAIL to compile because `ProfileViewModel`, its state, and fake controls are absent. +Record the compile error for `ProfileViewModel` or `selectionFailure` as the RED evidence. A failure +caused by `JAVA_HOME`, dependency resolution, or another test does not count. At RED, only the two +fake files and `ProfileViewModelTest.kt` may be modified. + +- [ ] **Step 4: Define stable UI state with independent insight loads** + +```kotlin +sealed interface ProfileLoadable { + data object Empty : ProfileLoadable + data object Loading : ProfileLoadable + data class Ready(val value: T) : ProfileLoadable + data class Failed(val cause: Throwable) : ProfileLoadable +} + +data class ProfilePrHighlights( + val maxWeightPerCableKg: Float?, + val estimatedOneRepMaxPerCableKg: Float?, + val maxVolumeKg: Float?, +) + +data class ProfileUiState( + val context: ActiveProfileContext? = null, + val selectedExercise: Exercise? = null, + val missingExerciseId: String? = null, + val selectionFailure: Throwable? = null, + val currentOneRepMax: ProfileLoadable = ProfileLoadable.Empty, + val prHighlights: ProfileLoadable = ProfileLoadable.Empty, + val recentSessions: ProfileLoadable> = ProfileLoadable.Empty, +) + +sealed interface ProfileUiEvent { + data object PreferenceUpdateFailed : ProfileUiEvent + data object IdentityUpdateFailed : ProfileUiEvent + data object IdentityUpdated : ProfileUiEvent + data object ProfileDeleted : ProfileUiEvent +} +``` + +`missingExerciseId` means the repository successfully resolved an ID that the exercise catalog no +longer contains. `selectionFailure` means selection/fallback lookup failed; never translate a +repository exception into "missing exercise." This is the Task 6 boundary: that consumer must +render `selectionFailure` with `profile_insights_load_failed` before its ordinary null-selection +empty state. Do not put localized strings or raw JSON in the ViewModel. + +- [ ] **Step 5: Implement profile-bound selection lifecycle** + +Construct the ViewModel with the existing Koin-bound repository types: + +```kotlin +class ProfileViewModel( + private val profiles: UserProfileRepository, + private val exercises: ExerciseRepository, + private val workouts: WorkoutRepository, + private val personalRecords: PersonalRecordRepository, + private val resolveCurrentOneRepMax: ResolveCurrentOneRepMaxUseCase, + private val externalMeasurements: ExternalMeasurementRepository, +) : ViewModel() +``` + +`externalMeasurements` is intentionally constructor-stable but unused until Task 8 adds +profile-scoped body-weight attribution; retain it and use `FakeExternalMeasurementRepository` in +Task 5 tests. + +Maintain per-profile selection only inside this route-scoped instance. A second Ready emission for +the **same** profile is normal: `updateProfile`, every preference mutation, and local-safety updates +all republish `ActiveProfileContext.Ready`. It must refresh `context` without reconstructing +`ProfileUiState`, clearing loadables, resetting later mutation-busy fields, or re-querying insights. + +```kotlin +private val _uiState = MutableStateFlow(ProfileUiState()) +val uiState: StateFlow = _uiState.asStateFlow() + +private val selectedExerciseIds = mutableMapOf() +private var resolvedSelectionProfileId: String? = null +private var insightsJob: Job? = null + +init { + viewModelScope.launch { + profiles.activeProfileContext.collectLatest { context -> + when (context) { + is ActiveProfileContext.Switching -> { + insightsJob?.cancel() + resolvedSelectionProfileId = null + _uiState.value = ProfileUiState(context = context) + } + + is ActiveProfileContext.Ready -> applyReadyContext(context) + } + } + } +} + +private suspend fun applyReadyContext(context: ActiveProfileContext.Ready) { + val profileId = context.profile.id + val currentProfileId = + (_uiState.value.context as? ActiveProfileContext.Ready)?.profile?.id + + if (currentProfileId == profileId && resolvedSelectionProfileId == profileId) { + updateIfProfileCurrent(profileId) { state -> state.copy(context = context) } + return + } + + insightsJob?.cancel() + _uiState.value = ProfileUiState(context = context) + try { + require(profileId.isNotBlank()) { "Ready profile ID must not be blank" } + val savedId = selectedExerciseIds[profileId] + val saved = savedId?.let { validExercise(it) } + val fallbackId = if (saved == null) { + workouts.getMostRecentCompletedExerciseId(profileId) + } else { + null + } + val fallback = fallbackId?.let { validExercise(it) } + val selected = saved ?: fallback + if (!isProfileCurrent(profileId)) return + + resolvedSelectionProfileId = profileId + selected?.id?.let { selectedExerciseIds[profileId] = it } + updateIfProfileCurrent(profileId) { state -> + state.copy( + context = context, + selectedExercise = selected, + missingExerciseId = (fallbackId ?: savedId) + ?.takeIf { it.isNotBlank() && selected == null }, + selectionFailure = null, + ) + } + selected?.let { loadInsights(profileId, it) } + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + resolvedSelectionProfileId = null + updateIfProfileCurrent(profileId) { state -> + state.copy( + context = context, + selectedExercise = null, + missingExerciseId = null, + selectionFailure = error, + ) + } + } +} + +private suspend fun validExercise(requestedId: String): Exercise? { + if (requestedId.isBlank()) return null + return exercises.getExerciseById(requestedId) + ?.takeIf { it.id == requestedId && !it.id.isNullOrBlank() } +} +``` + +Guard both repository truth and the potentially lagging UI snapshot. This prevents a picker tap after +the repository entered Switching from being saved under the previous profile: + +```kotlin +fun selectExercise(exercise: Exercise) { + val exerciseId = exercise.id?.takeIf { it.isNotBlank() } ?: return + val uiReady = uiState.value.context as? ActiveProfileContext.Ready ?: return + val repositoryReady = + profiles.activeProfileContext.value as? ActiveProfileContext.Ready ?: return + val profileId = uiReady.profile.id + if (repositoryReady.profile.id != profileId) return + + selectedExerciseIds[profileId] = exerciseId + resolvedSelectionProfileId = profileId + updateIfProfileCurrent(profileId) { state -> + state.copy( + selectedExercise = exercise, + missingExerciseId = null, + selectionFailure = null, + ) + } + if (isSelectionCurrent(profileId, exerciseId)) { + loadInsights(profileId, exercise) + } +} + +private fun isProfileCurrent(profileId: String): Boolean { + val repositoryReady = + profiles.activeProfileContext.value as? ActiveProfileContext.Ready ?: return false + val uiReady = uiState.value.context as? ActiveProfileContext.Ready ?: return false + return repositoryReady.profile.id == profileId && uiReady.profile.id == profileId +} + +private fun isSelectionCurrent(profileId: String, exerciseId: String): Boolean = + isProfileCurrent(profileId) && + uiState.value.selectedExercise?.id == exerciseId && + selectedExerciseIds[profileId] == exerciseId + +private inline fun updateIfProfileCurrent( + profileId: String, + transform: (ProfileUiState) -> ProfileUiState, +) { + _uiState.update { state -> + val repositoryReady = profiles.activeProfileContext.value as? ActiveProfileContext.Ready + val uiReady = state.context as? ActiveProfileContext.Ready + if (repositoryReady?.profile?.id == profileId && uiReady?.profile?.id == profileId) { + transform(state) + } else { + state + } + } +} + +private inline fun updateIfSelectionCurrent( + profileId: String, + exerciseId: String, + transform: (ProfileUiState) -> ProfileUiState, +) { + _uiState.update { state -> + val repositoryReady = profiles.activeProfileContext.value as? ActiveProfileContext.Ready + val uiReady = state.context as? ActiveProfileContext.Ready + if ( + repositoryReady?.profile?.id == profileId && + uiReady?.profile?.id == profileId && + state.selectedExercise?.id == exerciseId && + selectedExerciseIds[profileId] == exerciseId + ) { + transform(state) + } else { + state + } + } +} +``` + +Do not guard publication with only `selectedExerciseIds`: that map intentionally retains A while B +is active. Do not catch selection lookup with `runCatching`, because it also catches cancellation. + +- [ ] **Step 6: Load 1RM, PR highlights, and five sessions independently** + +Set all three branches to Loading in one guarded update, then start them as children of one +`supervisorScope`. The helper rethrows cancellation before the ordinary `Exception` catch and +checks repository profile, UI profile, selected exercise, and saved selection again before every +publication: + +```kotlin +private fun loadInsights(profileId: String, exercise: Exercise) { + val exerciseId = exercise.id?.takeIf { it.isNotBlank() } ?: return + insightsJob?.cancel() + updateIfSelectionCurrent(profileId, exerciseId) { state -> + state.copy( + currentOneRepMax = ProfileLoadable.Loading, + prHighlights = ProfileLoadable.Loading, + recentSessions = ProfileLoadable.Loading, + ) + } + if (!isSelectionCurrent(profileId, exerciseId)) return + + insightsJob = viewModelScope.launch { + supervisorScope { + launch { + loadBranch( + profileId = profileId, + exerciseId = exerciseId, + load = { + resolveCurrentOneRepMax(exerciseId, profileId) + ?.let { ProfileLoadable.Ready(it) } + ?: ProfileLoadable.Empty + }, + publish = { state, value -> state.copy(currentOneRepMax = value) }, + ) + } + launch { + loadBranch( + profileId = profileId, + exerciseId = exerciseId, + load = { + ProfileLoadable.Ready( + personalRecords.getAllPRsForExercise(exerciseId, profileId) + .toHighlights(), + ) + }, + publish = { state, value -> state.copy(prHighlights = value) }, + ) + } + launch { + loadBranch( + profileId = profileId, + exerciseId = exerciseId, + load = { + ProfileLoadable.Ready( + workouts.getRecentCompletedSessionsForExercise( + exerciseId = exerciseId, + profileId = profileId, + limit = MAX_RECENT_EXERCISE_SESSIONS, + ), + ) + }, + publish = { state, value -> state.copy(recentSessions = value) }, + ) + } + } + } +} + +private suspend fun loadBranch( + profileId: String, + exerciseId: String, + load: suspend () -> ProfileLoadable, + publish: (ProfileUiState, ProfileLoadable) -> ProfileUiState, +) { + val result: ProfileLoadable = try { + load() + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + ProfileLoadable.Failed(error) + } + updateIfSelectionCurrent(profileId, exerciseId) { state -> + publish(state, result) + } +} +``` + +Use the canonical PR field mappings and reject non-finite/non-positive values independently: + +```kotlin +private fun List.toHighlights() = ProfilePrHighlights( + maxWeightPerCableKg = asSequence() + .filter { it.prType == PRType.MAX_WEIGHT } + .map { it.weightPerCableKg } + .filter { it.isFinite() && it > 0f } + .maxOrNull(), + estimatedOneRepMaxPerCableKg = asSequence() + .map { it.oneRepMax } + .filter { it.isFinite() && it > 0f } + .maxOrNull(), + maxVolumeKg = asSequence() + .filter { it.prType == PRType.MAX_VOLUME } + .map { it.volume } + .filter { it.isFinite() && it > 0f } + .maxOrNull(), +) +``` + +Import `MAX_RECENT_EXERCISE_SESSIONS`. Treat a null resolver result as `ProfileLoadable.Empty`, not failure. Treat no PR rows as `Ready(ProfilePrHighlights(null, null, null))` and no sessions as `Ready(emptyList())`. + +When velocity and assessment are absent, Task 2's resolver reads the same bounded history API that +the recent-sessions branch reads, so one load may produce two identical +`recentCompletedRequests`. Tests must not assert a single call. To isolate a recent-history failure +from the 1RM branch, seed a valid velocity or assessment result first. + +- [ ] **Step 7: Register the route-scoped factory and make tests green** + +```kotlin +import com.devil.phoenixproject.presentation.viewmodel.ProfileViewModel + +factory { + ProfileViewModel( + profiles = get(), + exercises = get(), + workouts = get(), + personalRecords = get(), + resolveCurrentOneRepMax = get(), + externalMeasurements = get(), + ) +} +``` + +All six dependencies already exist in `dataModule`/`domainModule`; do not add duplicate bindings. +Run the focused tests, fake regressions, Koin verification, and both production/test Native +compilers in one forced gate: + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' ` + :shared:testAndroidHostTest ` + --tests "*ProfileViewModelTest*" ` + --tests "*ResolveCurrentOneRepMaxUseCaseTest*" ` + --tests "*KoinModuleVerifyTest*" ` + --tests "*SqlDelightUserProfileRepositoryTest*" ` + --tests "*PersonalRecordRepositoryTest*" ` + :shared:compileAndroidMain ` + :shared:compileKotlinIosArm64 ` + :shared:compileTestKotlinIosArm64 ` + --rerun-tasks ` + --console=plain +``` + +Expected: BUILD SUCCESSFUL. `compileTestKotlinIosArm64` is required because both modified fakes live +in `commonTest`; an Android-host pass alone does not prove their Native compatibility. + +Assert the focused suite actually executed all fourteen tests: + +```powershell +$resultPath = 'shared/build/test-results/testAndroidHostTest/' + + 'TEST-com.devil.phoenixproject.presentation.viewmodel.ProfileViewModelTest.xml' +[xml]$result = Get-Content -Raw $resultPath +$suite = $result.testsuite +if ( + [int]$suite.tests -ne 14 -or + [int]$suite.failures -ne 0 -or + [int]$suite.errors -ne 0 -or + [int]$suite.skipped -ne 0 +) { + throw "Unexpected ProfileViewModelTest result: tests=$($suite.tests) " + + "failures=$($suite.failures) errors=$($suite.errors) skipped=$($suite.skipped)" +} +``` + +Run intent-specific static checks: + +```powershell +$viewModel = Get-Content -Raw ` + 'shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileViewModel.kt' +$presentation = Get-Content -Raw ` + 'shared/src/commonMain/kotlin/com/devil/phoenixproject/di/PresentationModule.kt' + +if ([regex]::Matches($presentation, 'factory\s*\{\s*ProfileViewModel\(').Count -ne 1) { + throw 'Expected exactly one route-scoped ProfileViewModel factory' +} +if ($viewModel -match 'catch\s*\([^)]*Throwable') { + throw 'ProfileViewModel must not catch Throwable' +} +$cancellationCatch = $viewModel.IndexOf('catch (error: CancellationException)') +$ordinaryCatch = $viewModel.IndexOf('catch (error: Exception)') +if ($cancellationCatch -lt 0 -or $ordinaryCatch -lt 0 -or $cancellationCatch -gt $ordinaryCatch) { + throw 'CancellationException must be caught and rethrown before ordinary Exception' +} +foreach ($required in @( + 'catch (error: CancellationException)', + 'catch (error: Exception)', + 'profiles.activeProfileContext.value', + 'updateIfProfileCurrent', + 'updateIfSelectionCurrent', + 'resolvedSelectionProfileId', + 'selectionFailure', + 'MAX_RECENT_EXERCISE_SESSIONS' +)) { + if (-not $viewModel.Contains($required)) { + throw "ProfileViewModel is missing required lifecycle guard: $required" + } +} +``` + +Expected: exactly one factory, no broad `Throwable` catch, and every captured-context guard present. + +- [ ] **Step 8: Commit the profile state layer** + +```powershell +$expected = @( + 'shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileViewModel.kt' + 'shared/src/commonMain/kotlin/com/devil/phoenixproject/di/PresentationModule.kt' + 'shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeUserProfileRepository.kt' + 'shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakePersonalRecordRepository.kt' + 'shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileViewModelTest.kt' +) | Sort-Object +$actual = @(git status --porcelain=v1 | ForEach-Object { $_.Substring(3) } | Sort-Object) +$scopeDiff = Compare-Object -ReferenceObject $expected -DifferenceObject $actual +if ($scopeDiff) { + $scopeDiff | Format-Table -AutoSize + throw 'Task 5 worktree scope differs from the five-file state contract' +} +git diff --check -- $expected +if ($LASTEXITCODE -ne 0) { throw 'Task 5 diff check failed' } +git add -- $expected +$staged = @(git diff --cached --name-only | Sort-Object) +$stagedDiff = Compare-Object -ReferenceObject $expected -DifferenceObject $staged +if ($stagedDiff) { + $stagedDiff | Format-Table -AutoSize + throw 'Task 5 staged scope differs from the five-file state contract' +} +git diff --cached --check +if ($LASTEXITCODE -ne 0) { throw 'Task 5 cached diff check failed' } +git commit -m "feat: add profile scoped insights state" +``` + +--- + +### Task 6: Build the Compact Profile Header and Exercise Insights Surface + +**Files:** +- Create: `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileExerciseInsights.kt` +- Create: `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ProfileScreen.kt` +- Create: `shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ProfileScreenContractTest.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/UserProfileRepository.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileViewModel.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/util/TestTags.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/util/KmpUtils.kt` +- Modify: `shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightUserProfileRepositoryTest.kt` +- Modify: `shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileViewModelTest.kt` +- Modify: `shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeUserProfileRepository.kt` +- Modify: `shared/src/commonTest/kotlin/com/devil/phoenixproject/util/KmpUtilsTest.kt` + +**Interfaces:** +- Consumes: Task 4's callback-only identity/dialog components and single-owner action tags; Task 5's stable `selectionFailure`, selected/missing exercise state, and three independent `ProfileLoadable` blocks; `ExercisePickerDialog`; `VolumeHistoryChart`; `WeightDisplayFormatter`; and `WorkoutSession.effectiveTotalVolumeKg`. +- Produces: atomic `UserProfileRepository.deleteActiveProfile(expectedProfileId)`, explicit `KmpUtils` support for `"MMM d"`, token/profile/kind-scoped identity mutation state and events, `buildProfileRecentHistory`, a compiling `ProfileScreen`, active-profile edit/delete actions, and compact independently resilient insight cards. Task 7 receives `onProfileRecoveryRequired` and can register the finished route without taking ownership of mutation state. +- Identity invariant: the UI may delete only the active non-default profile, and the repository validates that same identity while holding `profileContextMutex`; a concurrent switch can never make the delete copy's Default reassignment claim false. +- Overlay invariant: picker/edit/delete state is keyed by profile ID, terminal events are matched to that target, and a Switching/new-Ready context cannot retarget an already-open overlay. +- Accessibility/tag invariant: all selector/header actions have 48dp targets and localized names; `SCREEN_PROFILE` belongs to the screen root, `ACTION_EDIT_PROFILE`/`ACTION_DELETE_PROFILE` belong only to header triggers, and Task 4 dialogs do not reuse action tags on confirmation buttons. + +#### Hardened Task 6 execution contract (authoritative) + +This block supersedes any older Task 6 snippet below where the two disagree. Do not begin +production edits until the expanded red suite reaches the compiler/test runner. + +**Atomic active deletion** + +- Add `suspend fun deleteActiveProfile(expectedProfileId: String): Boolean` to + `UserProfileRepository`; retain generic `deleteProfile` as a backward-compatible repository API after Task 7 removes the legacy side panel. +- In `SqlDelightUserProfileRepository`, validate the current `ActiveProfileContext.Ready` and + `expectedProfileId` while already holding `profileContextMutex`. A mismatch throws + `StaleProfileContextException`; Switching throws `ProfileContextUnavailableException`. +- Refactor the existing deletion body into one lock-owned helper. The active-only path requires a + non-default active row and always reassigns to Default. There must be no check-then-lock gap and + no nested acquisition of `profileContextMutex`. +- Give `FakeUserProfileRepository` exact parity, plus update/delete call histories, ordinary + failure seams, and suspend gates used by deterministic cancellation/race tests. +- Add two SQLDelight repository tests: a stale expected ID after a completed switch throws without + mutating either profile, and an active deletion racing a queued switch can only (a) delete and + reassign to Default before the switch or (b) fail stale before deletion. It may never reassign + the deleted profile to the newly active non-default profile. + +**Scoped identity mutations** + +- Replace the bare Boolean operation model with token/profile/kind ownership: + +```kotlin +enum class ProfileIdentityMutationKind { UPDATE, DELETE } + +data class ProfileIdentityMutation( + val token: Long, + val profileId: String, + val kind: ProfileIdentityMutationKind, +) +``` + +- `ProfileUiState` owns `identityMutation: ProfileIdentityMutation?`; expose + `identityMutationInFlight` as `identityMutation != null`. Task 5's Switching/new-profile reset + paths must preserve the current mutation record until its owning job clears it. +- Scope terminal events with IDs/kinds: + +```kotlin +data class IdentityUpdated(val profileId: String) : ProfileUiEvent +data class IdentityUpdateFailed( + val profileId: String, + val kind: ProfileIdentityMutationKind, +) : ProfileUiEvent +data class ProfileDeleted(val profileId: String) : ProfileUiEvent +data class ProfileRecoveryRequired( + val profileId: String, + val cause: ProfileContextRecoveryException, +) : ProfileUiEvent +``` + +- Capture only when repository and UI contexts are both Ready for the same profile. Reject blank + names, default-profile deletion, stale pairs, and overlaps before launching. Normalize submitted + color indices with Task 4's helper. +- Allocate the token and publish busy state synchronously before returning from the public action, + so two calls in the same frame cannot both launch. Recheck current ownership immediately before + an update repository call. Switching cancels an UPDATE job but must not blindly cancel DELETE, + because successful active deletion emits its own Switching(Default) transition. +- Rethrow `CancellationException` and emit no failure event. Ordinary exceptions emit a matching + scoped failure only if the token still owns the operation. A + `ProfileContextRecoveryException` emits only `ProfileRecoveryRequired` for Task 7's blocking + recovery owner; it is never reduced to `profile_update_failed`. +- Clear busy state (only if the finishing token still owns it) before sending a terminal event. + Suppress stale update success/failure after a profile switch. Call the new atomic + `deleteActiveProfile(capturedProfileId)` for deletion. + +Add exactly eleven identity-mutation tests to the existing fourteen ViewModel tests (25 total): + +1. trimmed/normalized update targets the current Ready ID and success is observed only after the + repository value is authoritative; +2. ordinary update failure leaves authoritative state, clears its token, and emits one scoped + failure; +3. cancellation emits no failure/success and clears only its own token; +4. an overlapping update/delete call is rejected synchronously; +5. a same-profile Ready refresh preserves the in-flight token and does not restart insights; +6. a gated update followed by A→B cannot mutate B or publish a stale A event; +7. Default deletion never reaches the repository; +8. successful active deletion emits one scoped `ProfileDeleted` after busy clears; +9. repository `false` emits one scoped delete failure; +10. an ordinary delete exception emits one scoped delete failure without closing state; and +11. `ProfileContextRecoveryException` emits only the scoped recovery event. + +**Compact insights and screen state** + +- Add `selectionFailure: Throwable?` to `ProfileExerciseInsights`. With no selection, precedence is + selection failure → missing exercise → ordinary no-history. Keep the exercise selector visible + in every case so manual selection can recover. +- `CurrentOneRepMaxSource` has only VELOCITY/ASSESSMENT/SESSION. Map those three resources; use + `profile_one_rep_max_source_none` only for `ProfileLoadable.Empty`. +- `ProfilePrHighlights.maxVolumeKg` is a legacy-named per-cable `kg × reps` value from + `PersonalRecord.volume`, not a total. Convert its kg magnitude exactly once for LB display and + never multiply it by cable count. Use `effectiveTotalVolumeKg()` only for recent sessions. +- Add a pure `buildProfileRecentHistory` helper. Sort defensively by timestamp DESC then ID DESC, + take five for the newest-first accessible list, reverse that bounded list for oldest→newest chart + order, and create chart points only for finite positive total volume. An empty valid chart does + not hide the session list. +- Add explicit `"MMM d"` handling to `KmpUtils.formatTimestamp` and a focused common test proving it + does not fall through to numeric `MM/dd/yyyy`. +- The exercise selector is a named `Role.Button` with a 48dp minimum. Header switch/edit/delete + triggers are named 48dp controls and all are disabled during any identity mutation. Edit/delete + tags appear only on these header triggers. Treat the chart as decorative because the accessible + newest-first list conveys the same sessions. +- Add `TestTags.SCREEN_PROFILE` in this task. For a non-Ready context, use a full-size centered + `Column` with `LoadingIndicator(LoadingIndicatorSize.Large)` and localized + `profile_switching`; `LoadingIndicator` has no `message` parameter. +- Store `pickerProfileId`, `editTargetProfileId`, and `deleteTargetProfileId`, not free-floating + Booleans. Render each overlay only when its target equals the current Ready ID. Clear mismatched + targets on Switching/profile changes. Match scoped terminal events by ID and kind before closing; + ordinary matching failure keeps the dialog open and shows one snackbar. Forward a matching + recovery event through `onProfileRecoveryRequired` for Task 7. + +Add exactly six `ProfileScreenContractTest` cases: + +1. `SCREEN_PROFILE` exists and the non-Ready loader uses the real `LoadingIndicator` API; +2. selection failure has precedence over missing/empty copy while the picker remains available; +3. picker, all insight resources, source-empty handling, and no `Exercise.oneRepMaxKg` legacy read; +4. the pure recent-history helper proves deterministic newest-first list, oldest-first chart, five + row cap, and nonfinite/nonpositive chart filtering; +5. PR max-volume display documents per-cable semantics and never applies cable multiplication; and +6. profile-keyed overlays, scoped event matching, 48dp named header controls, and single-owner tags. + +**Required red/green and scope gates** + +The red run includes all four focused families and records the missing atomic API/screen symbols: + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest ` + --tests "*ProfileViewModelTest*" ` + --tests "*ProfileScreenContractTest*" ` + --tests "*SqlDelightUserProfileRepositoryTest*" ` + --tests "*KmpUtilsTest*" --console=plain +``` + +The final uncached gate is: + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest ` + --tests "*ProfileViewModelTest*" ` + --tests "*ProfileScreenContractTest*" ` + --tests "*SqlDelightUserProfileRepositoryTest*" ` + --tests "*KmpUtilsTest*" ` + :shared:compileAndroidMain :shared:compileKotlinIosArm64 :shared:compileTestKotlinIosArm64 ` + --rerun-tasks --console=plain +``` + +Require 25 ViewModel tests, 6 screen-contract tests, 2 new atomic-deletion tests within the existing +repository suite, and the compact-date test, all with zero failures/errors/skips. Run +`git diff --check` over exactly the eleven Task 6 files and verify both worktree and staged scope +before commit. No navigation, Settings, RoutinesTab, or resource bundle is in Task 6 scope. + +- [ ] **Step 1: Write the expanded failing repository, mutation, formatter, and screen tests** + +The authoritative matrix above replaces the smaller starter examples below: implement all +11 mutation, 6 screen, 2 atomic-delete, and 1 compact-date cases before production code. + +Add `updateProfileFailure` and `deleteProfileFailure` test controls to `FakeUserProfileRepository`, throwing before mutating when set. Then add ViewModel tests proving: + +- `updateIdentity` trims the name, passes the current Ready profile ID, and emits `IdentityUpdated` only after repository success. +- an update exception emits `IdentityUpdateFailed` and leaves the Ready repository value authoritative. +- `deleteActiveProfile` never calls the repository for ID `default`. +- successful non-default deletion emits `ProfileDeleted`; `false` or an exception emits `IdentityUpdateFailed`. + +Create the source contract test: + +```kotlin +package com.devil.phoenixproject.presentation.screen + +import com.devil.phoenixproject.testutil.readProjectFile +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertTrue + +class ProfileScreenContractTest { + @Test + fun profileInsightsUseExistingPickerAndCompactHistory() { + val screen = source( + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ProfileScreen.kt", + ) + val insights = source( + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileExerciseInsights.kt", + ) + + assertTrue(screen.contains("ExercisePickerDialog(")) + assertTrue(screen.contains("enableCustomExercises = false")) + assertTrue(insights.contains("VolumeHistoryChart(")) + assertTrue(insights.contains("ProfileLoadable.Failed")) + assertTrue(insights.contains("take(5)")) + } + + @Test + fun profileIdentityAndInsightsConsumeTheirCompleteResourceInventory() { + val screen = source( + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ProfileScreen.kt", + ) + val insights = source( + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileExerciseInsights.kt", + ) + + assertUsesResources( + screen, + listOf("switch_profile", "profile_switching", "profile_update_failed"), + ) + assertUsesResources( + insights, + listOf( + "profile_exercise_insights", + "profile_choose_exercise", + "profile_no_exercise_history", + "profile_current_one_rep_max", + "profile_one_rep_max_source_velocity", + "profile_one_rep_max_source_assessment", + "profile_one_rep_max_source_session", + "profile_one_rep_max_source_none", + "profile_pr_highlights", + "profile_pr_max_weight", + "profile_pr_estimated_one_rep_max", + "profile_pr_max_volume", + "profile_recent_history", + "profile_view_full_history", + "profile_missing_exercise", + "profile_insights_load_failed", + ), + ) + } + + private fun source(path: String): String = requireNotNull(readProjectFile(path), path) + + private fun assertUsesResources(source: String, keys: List) { + keys.forEach { key -> assertContains(source, "Res.string.$key", key) } + } +} +``` + +- [ ] **Step 2: Run the four-family red gate and confirm the missing atomic/UI contracts** + +Run: + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest ` + --tests "*ProfileViewModelTest*" --tests "*ProfileScreenContractTest*" ` + --tests "*SqlDelightUserProfileRepositoryTest*" --tests "*KmpUtilsTest*" --console=plain +``` + +Expected: FAIL for the missing atomic active-delete API, scoped identity state/events, compact date +formatter, history helper, and Profile UI files. Use the authoritative four-family command above. + +- [ ] **Step 3: Add atomic deletion and scoped identity mutations** + +Implement the lock-owned repository API, fake parity, token/profile/kind state, scoped events, +cancellation/recovery distinctions, and owner-only busy clearing exactly as specified above. Keep +events on a buffered one-shot channel; never replay them on recomposition. + +- [ ] **Step 4: Build the compact insight component** + +Use this boundary: + +```kotlin +@Composable +fun ProfileExerciseInsights( + selectedExercise: Exercise?, + missingExerciseId: String?, + selectionFailure: Throwable?, + currentOneRepMax: ProfileLoadable, + prHighlights: ProfileLoadable, + recentSessions: ProfileLoadable>, + weightUnit: WeightUnit, + onChooseExercise: () -> Unit, + onViewFullHistory: (String) -> Unit, + modifier: Modifier = Modifier, +) +``` + +Implementation rules: + +- The first row is a full-width, 48dp-minimum selector showing the exercise display name or `profile_choose_exercise` and a search icon. +- If selection is null, render selection failure first, then `profile_missing_exercise`, then ordinary `profile_no_exercise_history`. Render no metric shells in those states, but keep the selector available. +- Current 1RM uses `WeightDisplayFormatter.formatPerCableWeight`, the selected unit suffix, and maps the three enum sources. `ProfileLoadable.Empty` alone consumes `profile_one_rep_max_source_none`. +- PR highlights are three compact equal-width cells. Max weight and estimated 1RM are per-cable displays. +- PR max volume is stored per-cable kg×reps: convert once and never multiply by cable count. +- Recent history uses `buildProfileRecentHistory`: newest-first deterministic list, oldest-first finite-positive chart points, explicit `"MMM d"`, a 112dp decorative chart, and an accessible compact list. +- A `Failed` branch renders `profile_insights_load_failed` only inside that metric card. Other cards remain visible. +- A nonempty recent list ends with `profile_view_full_history`; pass the nonblank selected exercise ID to the callback. + +- [ ] **Step 5: Compose ProfileScreen with header, dialogs, picker, and one snackbar host** + +Start with this route-ready API; Task 8 adds the preference callbacks below the insight component without changing navigation ownership: + +```kotlin +@Composable +fun ProfileScreen( + onOpenProfileSwitcher: () -> Unit, + onNavigateToExerciseDetail: (String) -> Unit, + onProfileRecoveryRequired: (ProfileContextRecoveryException) -> Unit, + enableVideoPlayback: Boolean, + themeMode: ThemeMode, + modifier: Modifier = Modifier, + viewModel: ProfileViewModel = koinViewModel(), + exerciseRepository: ExerciseRepository = koinInject(), +) { + val state by viewModel.uiState.collectAsState() + val ready = state.context as? ActiveProfileContext.Ready + var pickerProfileId by rememberSaveable { mutableStateOf(null) } + var editTargetProfileId by rememberSaveable { mutableStateOf(null) } + var deleteTargetProfileId by rememberSaveable { mutableStateOf(null) } + val snackbarHostState = remember { SnackbarHostState() } + + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, + modifier = modifier.testTag(TestTags.SCREEN_PROFILE), + ) { padding -> + if (ready == null) { + Column( + modifier = Modifier.fillMaxSize().padding(padding), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + LoadingIndicator(LoadingIndicatorSize.Large) + Text(stringResource(Res.string.profile_switching)) + } + } else { + LazyColumn( + contentPadding = PaddingValues( + start = 16.dp, + top = padding.calculateTopPadding() + 12.dp, + end = 16.dp, + bottom = padding.calculateBottomPadding() + 24.dp, + ), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item(key = "profile-header") { + ProfileHeaderCard( + profile = ready.profile, + identityMutationInFlight = state.identityMutationInFlight, + onSwitchProfile = onOpenProfileSwitcher, + onEdit = { editTargetProfileId = ready.profile.id }, + onDelete = { deleteTargetProfileId = ready.profile.id }, + ) + } + item(key = "exercise-insights") { + ProfileExerciseInsights( + selectedExercise = state.selectedExercise, + missingExerciseId = state.missingExerciseId, + selectionFailure = state.selectionFailure, + currentOneRepMax = state.currentOneRepMax, + prHighlights = state.prHighlights, + recentSessions = state.recentSessions, + weightUnit = ready.preferences.core.value.weightUnit, + onChooseExercise = { pickerProfileId = ready.profile.id }, + onViewFullHistory = onNavigateToExerciseDetail, + ) + } + } + } + } +} +``` + +The header is a compact Material 3 card with a 56dp `ProfileAvatar`, active name, visible `Switch Profile` button, edit icon, and—only when `canDeleteProfile(ready.profile)`—delete icon. Give every action a named 48dp target and attach edit/delete tags only to those header triggers. Call `ProfileEditDialog` and `ProfileDeleteDialog` outside the lazy list only when their target ID still equals the Ready profile; pass `state.identityMutationInFlight`. Match scoped ID/kind events before closing. On a matching ordinary failure, keep the dialog open and show the localized update error exactly once; forward only `ProfileRecoveryRequired` to `onProfileRecoveryRequired`. + +Render `ExercisePickerDialog` outside the list with: + +```kotlin +ExercisePickerDialog( + showDialog = pickerProfileId == ready?.profile?.id, + onDismiss = { pickerProfileId = null }, + onExerciseSelected = { exercise -> + pickerProfileId = null + viewModel.selectExercise(exercise) + }, + exerciseRepository = exerciseRepository, + enableVideoPlayback = enableVideoPlayback, + themeMode = themeMode, + enableCustomExercises = false, +) +``` + +Clear picker/edit/delete target IDs whenever the Ready profile ID changes or the context enters +Switching. Do not let a saved Boolean retarget an overlay to the next profile. + +- [ ] **Step 6: Run the authoritative uncached four-family and cross-target gate** + +Run: + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest ` + --tests "*ProfileViewModelTest*" --tests "*ProfileScreenContractTest*" ` + --tests "*SqlDelightUserProfileRepositoryTest*" --tests "*KmpUtilsTest*" ` + :shared:compileAndroidMain :shared:compileKotlinIosArm64 :shared:compileTestKotlinIosArm64 ` + --rerun-tasks --console=plain +``` + +Expected: BUILD SUCCESSFUL with the authoritative counts, zero failures/errors/skips, and Android/iOS main plus iOS test compilation. + +- [ ] **Step 7: Commit the Profile identity and insight UI** + +```powershell +git add shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileExerciseInsights.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ProfileScreen.kt shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ProfileScreenContractTest.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/UserProfileRepository.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileViewModel.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/util/TestTags.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/util/KmpUtils.kt shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightUserProfileRepositoryTest.kt shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileViewModelTest.kt shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeUserProfileRepository.kt shared/src/commonTest/kotlin/com/devil/phoenixproject/util/KmpUtilsTest.kt +git commit -m "feat: build profile identity and exercise insights" +``` + +--- + +### Task 7: Add the Fifth Root Tab and Shared Long-Press Profile Switcher + +**Files:** +- Create: `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileSwitcherViewModel.kt` +- Create: `shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileSwitcherViewModelTest.kt` +- Create: `shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/navigation/ProfileNavigationContractTest.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/navigation/NavigationRoutes.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/navigation/NavGraph.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/EnhancedMainScreen.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/JustLiftScreen.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileDialogs.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileSwitcherSheet.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileListItem.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/di/PresentationModule.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/util/TestTags.kt` +- Modify: `shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/components/ProfileIdentityPolicyTest.kt` +- Modify: `shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeUserProfileRepository.kt` +- Delete: `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileSidePanel.kt` +- Delete: `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileSpeedDial.kt` +- Delete: `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/EditProfileDialog.kt` +- Delete: `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/DeleteProfileDialog.kt` + +**Interfaces:** +- Consumes: Tasks 3 and 4's localized recovery copy and callback-only modal surfaces; Task 6's exact `ProfileScreen(..., onProfileRecoveryRequired: (ProfileContextRecoveryException) -> Unit, ...)` handoff; `UserProfileRepository.setActiveProfile`, `createAndActivateProfile`, and `reconcileActiveProfileContext`; and the full `ActiveProfileContext` sealed state, including `Switching(null)`. +- Produces: `NavigationRoutes.Profile`; the single canonical Analytics/Insights/Home/Profile/Settings root order; a root-scoped `ProfileSwitcherViewModel`; pointer and semantic long press with haptic feedback; inline modal errors; one blocking recovery owner; and removal of every Home/Just Lift legacy profile selector and repository-coupled identity dialog. +- Concurrency invariant: switch, create, and recovery-retry operations acquire a synchronous token before launching, only the owning token may publish a terminal state, and Task 6 recovery invalidates any stale root operation before showing recovery. +- Modal invariant: `switchingInFlight` is explicit and never inferred from a nullable target ID; `Switching(null)` disables rows, add, gestures, back, scrim, and dismissal. Errors render inside their owning modal and never queue behind it in a root snackbar. +- Ownership invariant: `ProfileSwitcherViewModel` is the only switch/create/reconcile caller in presentation code after this task. `ProfileScreen` and the Profile tab long press only dispatch callbacks to that root owner. + +#### Hardened Task 7 execution contract (authoritative) + +This block supersedes every older Task 7 snippet below where the two disagree. Execute this block +in order. Do not start production edits until the complete fake-supported red suite reaches the +compiler/test runner. + +- [ ] **Step A: Add deterministic fake seams and the complete failing test suites** + +Extend `FakeUserProfileRepository` without changing the production interface. Task 6 owns its +separately named update/delete controls; add these switch/create/reconcile controls exactly: + +```kotlin +data class CreateAndActivateRequest(val name: String, val colorIndex: Int) + +val setActiveProfileRequests = mutableListOf() +val createAndActivateRequests = mutableListOf() +var reconcileActiveProfileContextRequests: Int = 0 + +var setActiveProfileFailure: Throwable? = null +var createAndActivateProfileFailure: Throwable? = null +var reconcileActiveProfileContextFailure: Throwable? = null + +var beforeSetActiveProfile: (suspend (String) -> Unit)? = null +var beforeCreateAndActivateProfile: (suspend (String, Int) -> Unit)? = null +var beforeReconcileActiveProfileContext: (suspend () -> Unit)? = null + +fun resetRootProfileOperationControls() { + setActiveProfileRequests.clear() + createAndActivateRequests.clear() + reconcileActiveProfileContextRequests = 0 + setActiveProfileFailure = null + createAndActivateProfileFailure = null + reconcileActiveProfileContextFailure = null + beforeSetActiveProfile = null + beforeCreateAndActivateProfile = null + beforeReconcileActiveProfileContext = null +} +``` + +Each fake method records its request, invokes its suspend hook, checks its failure seam, and only +then enters the existing mutex-backed success path. Failure therefore occurs before mutation. +Hooks allow a test to suspend cooperatively or swallow cancellation deliberately; `reset` clears +every control. + +Create `ProfileSwitcherViewModelTest.kt` with `TestCoroutineRule`, fresh fakes per test, and exactly +these seventeen tests: + +1. `sheet opens during Switching null but cannot dismiss or start an operation`; +2. `successful switch calls the repository once and closes the sheet`; +3. `ordinary switch failure keeps the sheet open with only switch error`; +4. `switch recovery failure closes ordinary overlays and opens blocking recovery`; +5. `switch cancellation clears only its token and emits no error`; +6. `two same-frame switch requests launch only the first`; +7. `noncooperative stale switch completion cannot close a newer recovery state`; +8. `add opens from the sheet and dismiss returns to that sheet`; +9. `successful create and activate calls once and closes both overlays`; +10. `ordinary create failure keeps both overlays with only create error`; +11. `create recovery failure closes both overlays and opens blocking recovery`; +12. `two same-frame create confirmations launch only the first`; +13. `Task 6 recovery handoff cancels ownership and opens the same recovery state`; +14. `successful recovery retry clears the blocking modal`; +15. `failed recovery retry keeps the modal with only retry error`; +16. `two same-frame recovery retries launch only the first`; and +17. `recovery cancellation clears its token but keeps recovery blocking without an error`. + +Use `CompletableDeferred` gates in tests 6, 7, 12, and 16. The stale test must swallow +`CancellationException` inside the fake hook, return, and prove the old token cannot publish. +Construct recovery failures as +`ProfileContextRecoveryException(IllegalStateException("recovery"))`. Assert exact request +histories, token kind/target, overlay booleans, and the single matching error enum after every +transition. + +Create `ProfileNavigationContractTest.kt` with exactly eight source-contract cases: + +1. one `NavigationRoutes.Profile` and the exact five-value `BottomNavItem` order; +2. exactly five bottom-bar cells in canonical order with 8dp padding, 4dp spacing, equal weights, + and 48dp minimum targets; +3. a selectable-group parent and merged `Role.Tab`, selected, click, labeled long-click, and all + five stable navigation tags; +4. Profile pointer/semantic long press performs `HapticFeedbackType.LongPress`, opens only the + switcher, and does not invoke normal navigation; +5. normal Profile tap uses Home `popUpTo { saveState = true }`, `launchSingleTop`, and + `restoreState`, while bottom-bar visibility and selection include Profile; +6. `NavGraph` registers `ProfileScreen`, passes both root callbacks, and uses localized + `nav_profile` for the destination and shell title paths; +7. explicit `switchingInFlight`, inline switch/create/retry errors, and the non-dismissible + recovery dialog are present; and +8. Enhanced Main and Just Lift contain no legacy selector/add-dialog state and all four legacy + source files are absent. + +The tag contract must reject `Modifier.weight(1f).testTag(...)` as the only tag placement when the +item later calls `clearAndSetSemantics`; it must require the tag to be appended after the clear in +the item implementation. It must also assert `TestTags.SCREEN_PROFILE` is consumed but not declared +by Task 7, because Task 6 owns that constant. + +- [ ] **Step B: Run the new suites and record a genuine red state** + +Run: + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest ` + --tests "*ProfileSwitcherViewModelTest*" ` + --tests "*ProfileNavigationContractTest*" ` + --console=plain +``` + +Expected: FAIL at `compileAndroidHostTest` because `ProfileSwitcherViewModel` and its typed state do +not exist, plus navigation/source assertions remain red. At this point only the two new test files +and `FakeUserProfileRepository.kt` may differ from the Task 6 commit. A Java/tooling failure is not +valid RED evidence. + +- [ ] **Step C: Implement the root-scoped tokenized coordinator** + +Create `ProfileSwitcherViewModel.kt` with this stable public state boundary: + +```kotlin +enum class RootProfileOperationKind { SWITCH, CREATE, RECOVERY } + +data class RootProfileOperation( + val token: Long, + val kind: RootProfileOperationKind, + val targetProfileId: String? = null, +) + +enum class ProfileOverlayError { + SWITCH_FAILED, + CREATE_FAILED, + RECOVERY_RETRY_FAILED, +} + +data class ProfileSwitcherUiState( + val showSwitcher: Boolean = false, + val showAddDialog: Boolean = false, + val recoveryRequired: Boolean = false, + val operation: RootProfileOperation? = null, + val error: ProfileOverlayError? = null, +) + +class ProfileSwitcherViewModel( + private val profiles: UserProfileRepository, +) : ViewModel() { + private val _uiState = MutableStateFlow(ProfileSwitcherUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var nextToken = 0L + private var operationJob: Job? = null + + fun openSwitcher() + fun dismissSwitcher() + fun openAddDialog() + fun dismissAddDialog() + fun switchProfile(profileId: String) + fun createAndActivateProfile(name: String, colorIndex: Int) + fun requireRecovery(error: ProfileContextRecoveryException) + fun retryRecovery() +} +``` + +All public methods are called on the UI thread. `beginOperation(kind, target)` checks +`uiState.value.operation == null` and `!recoveryRequired`, allocates `++nextToken`, and writes the +operation into `_uiState` before returning or launching. Switch additionally requires a nonblank +target and repository `ActiveProfileContext.Ready`; it ignores the current Ready ID. Create requires +the add dialog, a nonblank trimmed name, and Ready. Retry requires `recoveryRequired`. + +Use one ownership helper for every terminal transition: + +```kotlin +private inline fun finishOwned( + token: Long, + transform: (ProfileSwitcherUiState) -> ProfileSwitcherUiState, +) { + _uiState.update { state -> + if (state.operation?.token == token) transform(state) else state + } +} + +private fun enterRecovery(error: ProfileContextRecoveryException) { + Logger.e(error) { "PROFILE_CONTEXT: root recovery required" } + operationJob?.cancel() + _uiState.value = ProfileSwitcherUiState(recoveryRequired = true) +} +``` + +For switch/create: rethrow `CancellationException`; route +`ProfileContextRecoveryException` only to `enterRecovery`; route an ordinary `Exception` only to +the matching inline error; and close overlays only on success. Clear the owning operation in every +terminal state. For retry, any ordinary/recovery exception maps to `RECOVERY_RETRY_FAILED` while +`recoveryRequired` stays true. Cancellation clears only the owned operation and retains recovery. +`requireRecovery` is Task 6's handoff and must enter the same state synchronously, invalidating any +old token before its coroutine can return. + +Register exactly one route-root factory in `PresentationModule.kt`: + +```kotlin +factory { ProfileSwitcherViewModel(profiles = get()) } +``` + +`EnhancedMainScreen` obtains it with a default `koinViewModel()` parameter. The Android root +`ViewModelStoreOwner` preserves the blocking recovery/overlay state through configuration +recreation; process-death recovery remains backed by the repository's durable transition journal. + +- [ ] **Step D: Make the Task 4 modals explicitly blocking and error-owning** + +Change the sheet boundary to distinguish an unknown target from no operation: + +```kotlin +@Composable +fun ProfileSwitcherSheet( + profiles: List, + activeProfileId: String?, + switchingInFlight: Boolean, + switchingTargetProfileId: String?, + errorMessage: String?, + onSelectProfile: (UserProfile) -> Unit, + onAddProfile: () -> Unit, + onDismiss: () -> Unit, +) + +internal fun canDismissProfileSwitcher(switchingInFlight: Boolean): Boolean = + !switchingInFlight +``` + +Use `rememberUpdatedState(switchingInFlight)` in both `confirmValueChange` and +`onDismissRequest`; set `sheetGesturesEnabled = !switchingInFlight`; disable every row and Add +action while true. The optional target controls only which row shows the progress indicator. Render +`errorMessage` below the title with error color and +`Modifier.semantics { liveRegion = LiveRegionMode.Polite }`. + +Extend `ProfileAddDialog` with `errorMessage: String?` and render it inside the dialog using the +same polite live-region semantics. Keep Task 4's submission guards and callback-only behavior. +Add: + +```kotlin +@Composable +fun ProfileRecoveryDialog( + isRetrying: Boolean, + errorMessage: String?, + onRetry: () -> Unit, +) { + AlertDialog( + onDismissRequest = {}, + title = { Text(stringResource(Res.string.profile_recovery_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(stringResource(Res.string.profile_recovery_message)) + errorMessage?.let { + Text( + text = it, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.semantics { + liveRegion = LiveRegionMode.Polite + }, + ) + } + } + }, + confirmButton = { + Button(onClick = onRetry, enabled = !isRetrying) { + Text(stringResource(Res.string.action_retry)) + } + }, + ) +} +``` + +Do not add a dismiss button or action tags to confirmation buttons. Update the existing seven-case +`ProfileIdentityPolicyTest` in place for the Boolean dismissal API, inline live regions, callback +boundary, and the absence of duplicate action tags; the suite remains exactly seven tests. + +- [ ] **Step E: Add the route, exact five-tab semantics, and localized title plumbing** + +Add `NavigationRoutes.Profile("profile")`. Replace the unused labeled enum with the single +canonical root order: + +```kotlin +enum class BottomNavItem(val route: String) { + ANALYTICS(NavigationRoutes.Analytics.route), + WORKOUT(NavigationRoutes.Home.route), + INSIGHTS(NavigationRoutes.SmartInsights.route), + PROFILE(NavigationRoutes.Profile.route), + SETTINGS(NavigationRoutes.Settings.route), +} +``` + +Iterate `BottomNavItem.entries` in `PhoenixBottomNavigationBar`; map icon, localized description, +selected state, callback, and test tag with exhaustive `when` expressions. This prevents a second +manual order from drifting. The Row uses +`Modifier.fillMaxWidth().height(barHeight).padding(horizontal = 8.dp).selectableGroup()` and +`Arrangement.spacedBy(4.dp)`. + +Use this item boundary; `testTag` is appended after `clearAndSetSemantics` so it survives the +semantic replacement: + +```kotlin +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun PhoenixBottomNavigationItem( + icon: ImageVector, + contentDescription: String, + selected: Boolean, + testTag: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + onLongClick: (() -> Unit)? = null, + longClickLabel: String? = null, +) { + Box( + modifier = modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .clip(MaterialTheme.shapes.large) + .combinedClickable( + role = Role.Tab, + onClick = onClick, + onLongClick = onLongClick, + ) + .clearAndSetSemantics { + this.contentDescription = contentDescription + role = Role.Tab + this.selected = selected + this.onClick(label = contentDescription) { onClick(); true } + if (onLongClick != null && longClickLabel != null) { + this.onLongClick(label = longClickLabel) { onLongClick(); true } + } + } + .testTag(testTag), + contentAlignment = Alignment.Center, + ) { + Icon(icon, contentDescription = null, modifier = Modifier.size(26.dp)) + } +} +``` + +Map all existing `NAV_ANALYTICS`, `NAV_WORKOUTS`, `NAV_INSIGHTS`, and `NAV_SETTINGS` tags and add +only `NAV_PROFILE`; Task 6 already adds `SCREEN_PROFILE`. The Profile long-click callback performs +`LocalHapticFeedback.current.performHapticFeedback(HapticFeedbackType.LongPress)` and then +`profileSwitcherViewModel.openSwitcher()`. Its ordinary click only navigates. + +Include Profile in `shouldShowBottomBar`. Its ordinary click uses exactly: + +```kotlin +navController.navigate(NavigationRoutes.Profile.route) { + popUpTo(NavigationRoutes.Home.route) { saveState = true } + launchSingleTop = true + restoreState = true +} +``` + +Compute `val profileTitle = stringResource(Res.string.nav_profile)` in Enhanced Main, add a required +`profileTitle: String` parameter to `getScreenTitle`, return it for the Profile route, and let +`getCompactScreenTitle` return that already-localized title. Do not hardcode English. + +- [ ] **Step F: Register ProfileScreen and wire the single recovery owner** + +Add both required callbacks to `NavGraph` before its default `modifier` parameter: + +```kotlin +onOpenProfileSwitcher: () -> Unit, +onProfileRecoveryRequired: (ProfileContextRecoveryException) -> Unit, +``` + +Register Profile immediately after Smart Insights with tab fade transitions. Capture localized +`nav_profile`, update the shell title in `LaunchedEffect(profileTitle)`, collect video preferences, +and call Task 6's exact route API: + +```kotlin +ProfileScreen( + onOpenProfileSwitcher = onOpenProfileSwitcher, + onNavigateToExerciseDetail = { exerciseId -> + navController.navigate(NavigationRoutes.ExerciseDetail.createRoute(exerciseId)) + }, + onProfileRecoveryRequired = onProfileRecoveryRequired, + enableVideoPlayback = userPreferences.enableVideoPlayback, + themeMode = themeMode, +) +``` + +In Enhanced Main collect `activeProfileContext` in addition to `allProfiles`; remove the separate +`activeProfile` collection. Derive: + +```kotlin +val readyProfileId = + (activeProfileContext as? ActiveProfileContext.Ready)?.profile?.id +val repositorySwitching = activeProfileContext is ActiveProfileContext.Switching +val repositorySwitchTarget = + (activeProfileContext as? ActiveProfileContext.Switching)?.targetProfileId +val localSwitchTarget = switcherState.operation + ?.takeIf { it.kind == RootProfileOperationKind.SWITCH } + ?.targetProfileId +val localOperationInFlight = switcherState.operation != null +val switchingInFlight = repositorySwitching || localOperationInFlight +val switchingTargetProfileId = repositorySwitchTarget ?: localSwitchTarget +``` + +Render the sheet, Add dialog, and recovery dialog after the root Scaffold. Their callbacks call only +the ViewModel methods. Map `ProfileOverlayError` to `profile_switch_failed`, +`profile_create_failed`, or `profile_recovery_retry_failed` only in its owning modal. There is no +root snackbar for these modal-bound errors. + +Pass these callbacks into `NavGraph`: + +```kotlin +onOpenProfileSwitcher = switcherViewModel::openSwitcher, +onProfileRecoveryRequired = switcherViewModel::requireRecovery, +``` + +The recovery dialog renders whenever `switcherState.recoveryRequired`; it remains present through +retry failure and clears only after successful reconciliation. Because `requireRecovery` resets +ordinary overlays and errors synchronously, a Task 6 identity recovery event can never fall through +to ProfileScreen's ordinary update snackbar or a root switch/create error. + +- [ ] **Step G: Remove every legacy selector in the same atomic task** + +Delete the Home-only `ProfileSidePanel` block and old Add dialog from Enhanced Main. Delete the +four legacy files listed in this task. Simplify `ProfileListItem` to a sheet-only API by removing +`onLongClick`, `combinedClickable`, and the legacy semantics branch; retain its `selectable` +radio/selected semantics and switching indicator. + +In `JustLiftScreen`, remove only `allProfiles`, `activeProfile`, `showAddProfileDialog`, +`rememberCoroutineScope`, `ProfileSidePanel`, old `AddProfileDialog`, and their imports. Retain +`UserProfileRepository`, `activeProfileContext`, and `readyProfileId`: Just Lift still needs the +Ready profile ID to bind workout configuration and must not fall back across profiles. + +Before deletion, run: + +```powershell +rg -n "\b(ProfileSidePanel|ProfileSpeedDial|EditProfileDialog|DeleteProfileDialog)\b" ` + shared/src/commonMain/kotlin +``` + +Expected before deletion: definitions plus the two known selector call sites only. After deletion, +the same command must exit 1. Also verify `ProfileColors`, `PROFILE_COLOR_COUNT`, `ProfileAvatar`, +and `Profile(Add|Edit|Delete)Dialog` resolve only to Task 4's extracted identity files and current +callers. + +- [ ] **Step H: Run the forced full gate and assert every suite actually executed** + +Run: + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' ` + :shared:testAndroidHostTest ` + --tests "*ProfileSwitcherViewModelTest*" ` + --tests "*ProfileNavigationContractTest*" ` + --tests "*ProfileIdentityPolicyTest*" ` + --tests "*ProfileScreenContractTest*" ` + --tests "*ProfileViewModelTest*" ` + --tests "*KoinModuleVerifyTest*" ` + :shared:compileAndroidMain ` + :shared:compileKotlinIosArm64 ` + :shared:compileTestKotlinIosArm64 ` + :androidApp:assembleDebug ` + --rerun-tasks ` + --console=plain +``` + +Expected: BUILD SUCCESSFUL. Assert XML counts and zero failures/errors/skips: exactly 17 +`ProfileSwitcherViewModelTest`, 8 `ProfileNavigationContractTest`, 7 +`ProfileIdentityPolicyTest`, 7 hardened `ProfileScreenContractTest`, 26 hardened +`ProfileViewModelTest`, and 1 `KoinModuleVerifyTest` test (66 total). The iOS test compiler is +mandatory because the fake and both common source-contract suites changed. + +Run static intent checks: no legacy selector symbols/files; exactly one Profile route, one +`ProfileSwitcherViewModel` factory, and five enum values; no `catch (Throwable)` in the new +ViewModel; every `CancellationException` catch precedes ordinary `Exception`; explicit +`switchingInFlight` reaches every sheet gate; no root `SnackbarHostState` is used for modal errors; +and the Task 6 recovery callback appears from ProfileScreen through NavGraph to the root owner. + +- [ ] **Step I: Audit exact scope and commit the atomic navigation replacement** + +Use this exact expected path set, including deletions: + +```powershell +$expected = @( + 'shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileSwitcherViewModelTest.kt' + 'shared/src/commonMain/kotlin/com/devil/phoenixproject/di/PresentationModule.kt' + 'shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/DeleteProfileDialog.kt' + 'shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/EditProfileDialog.kt' + 'shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileDialogs.kt' + 'shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileListItem.kt' + 'shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileSidePanel.kt' + 'shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileSpeedDial.kt' + 'shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileSwitcherSheet.kt' + 'shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/navigation/NavGraph.kt' + 'shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/navigation/NavigationRoutes.kt' + 'shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/EnhancedMainScreen.kt' + 'shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/JustLiftScreen.kt' + 'shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/util/TestTags.kt' + 'shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileSwitcherViewModel.kt' + 'shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/components/ProfileIdentityPolicyTest.kt' + 'shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/navigation/ProfileNavigationContractTest.kt' + 'shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeUserProfileRepository.kt' +) | Sort-Object +$actual = @(git status --porcelain=v1 | ForEach-Object { $_.Substring(3) } | Sort-Object) +$scopeDiff = Compare-Object -ReferenceObject $expected -DifferenceObject $actual +if ($scopeDiff) { $scopeDiff | Format-Table -AutoSize; throw 'Task 7 scope mismatch' } +git diff --check -- $expected +if ($LASTEXITCODE -ne 0) { throw 'Task 7 diff check failed' } +git add -A -- $expected +$staged = @(git diff --cached --name-only | Sort-Object) +$stagedDiff = Compare-Object -ReferenceObject $expected -DifferenceObject $staged +if ($stagedDiff) { $stagedDiff | Format-Table -AutoSize; throw 'Task 7 staged scope mismatch' } +git diff --cached --check +if ($LASTEXITCODE -ne 0) { throw 'Task 7 cached diff check failed' } +git commit -m "feat: add profile tab and sole root switcher" +``` + +After commit, require a clean worktree and verify `git show --format= --name-only HEAD` matches the +same eighteen paths exactly. + +- [ ] **Step 1: Write the failing navigation source contract** + +```kotlin +package com.devil.phoenixproject.presentation.navigation + +import com.devil.phoenixproject.testutil.readProjectFile +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ProfileNavigationContractTest { + private val routes = requireNotNull(readProjectFile( + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/navigation/NavigationRoutes.kt", + )) + private val main = requireNotNull(readProjectFile( + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/EnhancedMainScreen.kt", + )) + private val graph = requireNotNull(readProjectFile( + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/navigation/NavGraph.kt", + )) + private val dialogs = requireNotNull(readProjectFile( + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileDialogs.kt", + )) + + @Test + fun profileIsTheFourthOfFiveCanonicalRootItems() { + assertTrue(routes.contains("object Profile : NavigationRoutes(\"profile\")")) + val analytics = main.indexOf("Icons.Default.BarChart") + val workout = main.indexOf("Icons.Default.Home", analytics) + val insights = main.indexOf("Icons.Default.AutoAwesome", workout) + val profile = main.indexOf("Icons.Default.Person", insights) + val settings = main.indexOf("Icons.Default.Settings", profile) + assertTrue(analytics >= 0 && analytics < workout && workout < insights && insights < profile && profile < settings) + } + + @Test + fun profileItemHasPointerAndSemanticLongPress() { + assertTrue(main.contains("combinedClickable(")) + assertTrue(main.contains("HapticFeedbackType.LongPress")) + assertTrue(main.contains("this.onLongClick(")) + assertTrue(main.contains("onProfileLongClick")) + assertFalse(main.contains("widthIn(min = 64.dp")) + } + + @Test + fun graphUsesTheRootOwnedSwitcherCallback() { + assertTrue(graph.contains("composable(\n route = NavigationRoutes.Profile.route")) + assertTrue(graph.contains("onOpenProfileSwitcher = onOpenProfileSwitcher")) + assertTrue(main.contains("ProfileSwitcherSheet(")) + assertTrue(main.contains("createAndActivateProfile(")) + } + + @Test + fun contextRecoveryFailureCannotFallThroughToAnOrdinarySnackbar() { + assertTrue(main.contains("catch (error: ProfileContextRecoveryException)")) + assertTrue(main.contains("ProfileRecoveryDialog(")) + assertTrue(main.contains("profileRepository.reconcileActiveProfileContext()")) + } + + @Test + fun navigationAndRecoveryConsumeTheirCompleteResourceInventory() { + assertUsesResources(graph, listOf("nav_profile")) + assertUsesResources( + main, + listOf( + "cd_profile", + "cd_open_profile_switcher", + "profile_switch_failed", + "profile_create_failed", + "profile_recovery_retry_failed", + ), + ) + assertUsesResources( + dialogs, + listOf("profile_recovery_title", "profile_recovery_message"), + ) + } + + private fun assertUsesResources(source: String, keys: List) { + keys.forEach { key -> assertTrue(source.contains("Res.string.$key"), key) } + } +} +``` + +- [ ] **Step 2: Run the contract and confirm the red state** + +Run: + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest --tests "*ProfileNavigationContractTest*" --console=plain +``` + +Expected: FAIL because the Profile route/item/gesture/sheet wiring is absent. + +- [ ] **Step 3: Add the route and repair the stale BottomNavItem declaration** + +Add `object Profile : NavigationRoutes("profile")` between Smart Insights and Settings in the root-tab grouping. Replace the stale three-item enum with: + +```kotlin +enum class BottomNavItem(val route: String) { + ANALYTICS(NavigationRoutes.Analytics.route), + WORKOUT(NavigationRoutes.Home.route), + INSIGHTS(NavigationRoutes.SmartInsights.route), + PROFILE(NavigationRoutes.Profile.route), + SETTINGS(NavigationRoutes.Settings.route), +} +``` + +The enum holds no hardcoded labels; UI labels remain resources. + +- [ ] **Step 4: Register ProfileScreen in NavGraph** + +Add `onOpenProfileSwitcher: () -> Unit` to `NavGraph`. Insert the Profile destination immediately after Smart Insights: + +```kotlin +composable( + route = NavigationRoutes.Profile.route, + enterTransition = { NavTransitions.tabFadeEnter() }, + exitTransition = { NavTransitions.tabFadeExit() }, + popEnterTransition = { NavTransitions.tabFadeEnter() }, + popExitTransition = { NavTransitions.tabFadeExit() }, +) { + val userPreferences by viewModel.userPreferences.collectAsState() + ProfileScreen( + onOpenProfileSwitcher = onOpenProfileSwitcher, + onNavigateToExerciseDetail = { exerciseId -> + navController.navigate(NavigationRoutes.ExerciseDetail.createRoute(exerciseId)) + }, + enableVideoPlayback = userPreferences.enableVideoPlayback, + themeMode = themeMode, + ) +} +``` + +- [ ] **Step 5: Make EnhancedMainScreen the sole root switcher owner** + +Collect `activeProfileContext`, then add root overlay state: + +```kotlin +val activeProfileContext by profileRepository.activeProfileContext.collectAsState() +val switchingTargetProfileId = + (activeProfileContext as? ActiveProfileContext.Switching)?.targetProfileId +var showProfileSwitcher by rememberSaveable { mutableStateOf(false) } +var showAddProfileDialog by rememberSaveable { mutableStateOf(false) } +var createProfileInFlight by remember { mutableStateOf(false) } +var profileRecoveryFailure by remember { mutableStateOf(null) } +var profileRecoveryInFlight by remember { mutableStateOf(false) } +val shellSnackbarHost = remember { SnackbarHostState() } +val switchFailed = stringResource(Res.string.profile_switch_failed) +val createFailed = stringResource(Res.string.profile_create_failed) +val recoveryRetryFailed = stringResource(Res.string.profile_recovery_retry_failed) +``` + +Pass `{ showProfileSwitcher = true }` into `NavGraph`. Add `snackbarHost = { SnackbarHost(shellSnackbarHost) }` to the existing root Scaffold. Compose exactly one sheet outside its content lambda: + +```kotlin +if (showProfileSwitcher) { + ProfileSwitcherSheet( + profiles = profiles, + activeProfileId = (activeProfileContext as? ActiveProfileContext.Ready)?.profile?.id, + switchingTargetProfileId = switchingTargetProfileId, + onSelectProfile = { profile -> + if (profile.id != activeProfile?.id && switchingTargetProfileId == null) { + scope.launch { + try { + profileRepository.setActiveProfile(profile.id) + showProfileSwitcher = false + } catch (error: CancellationException) { + throw error + } catch (error: ProfileContextRecoveryException) { + showProfileSwitcher = false + profileRecoveryFailure = error + } catch (error: Exception) { + shellSnackbarHost.showSnackbar(switchFailed) + } + } + } + }, + onAddProfile = { showAddProfileDialog = true }, + onDismiss = { if (switchingTargetProfileId == null) showProfileSwitcher = false }, + ) +} +``` + +Replace the old repository-coupled Add dialog with Task 4's callback dialog: + +```kotlin +if (showAddProfileDialog) { + ProfileAddDialog( + existingProfileCount = profiles.size, + isSubmitting = createProfileInFlight, + onConfirm = { name, colorIndex -> + scope.launch { + createProfileInFlight = true + try { + profileRepository.createAndActivateProfile(name, colorIndex) + showAddProfileDialog = false + showProfileSwitcher = false + } catch (error: CancellationException) { + throw error + } catch (error: ProfileContextRecoveryException) { + showAddProfileDialog = false + showProfileSwitcher = false + profileRecoveryFailure = error + } catch (error: Exception) { + shellSnackbarHost.showSnackbar(createFailed) + } finally { + createProfileInFlight = false + } + } + }, + onDismiss = { if (!createProfileInFlight) showAddProfileDialog = false }, + ) +} +``` + +The dialog and sheet remain open on ordinary operation failure. Creation calls no separate `setActiveProfile`. A `ProfileContextRecoveryException` is different: it means the repository could not prove that its database activation and published context agree, so close the ordinary overlays and show a blocking recovery dialog instead of a snackbar. + +Add this callback-only component to `ProfileDialogs.kt`: + +```kotlin +@Composable +fun ProfileRecoveryDialog( + isRetrying: Boolean, + onRetry: () -> Unit, +) { + AlertDialog( + onDismissRequest = {}, + title = { Text(stringResource(Res.string.profile_recovery_title)) }, + text = { Text(stringResource(Res.string.profile_recovery_message)) }, + confirmButton = { + Button(onClick = onRetry, enabled = !isRetrying) { + Text(stringResource(Res.string.action_retry)) + } + }, + ) +} +``` + +Compose it after the sheet/add-dialog blocks. It has no dismiss path and calls the facade's reconciliation method: + +```kotlin +if (profileRecoveryFailure != null) { + ProfileRecoveryDialog( + isRetrying = profileRecoveryInFlight, + onRetry = { + if (!profileRecoveryInFlight) scope.launch { + profileRecoveryInFlight = true + try { + profileRepository.reconcileActiveProfileContext() + profileRecoveryFailure = null + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + shellSnackbarHost.showSnackbar(recoveryRetryFailed) + } finally { + profileRecoveryInFlight = false + } + } + }, + ) +} +``` + +Use Task 3's localized `profile_recovery_title`, `profile_recovery_message`, and `profile_recovery_retry_failed` resources. The retry succeeds only when `reconcileActiveProfileContext()` drains any durable transition journal, re-reads SQLite's actual active row, removes a failed-created identity/preferences pair when recorded, and publishes a matching `Ready`; otherwise the modal remains. Extend the navigation test with a fake repository that throws `ProfileContextRecoveryException`, assert that no ordinary create/switch snackbar event is used, and assert that a successful reconciliation clears the modal. + +- [ ] **Step 6: Fit five 48dp cells and wire pointer plus semantic long press** + +Add Profile parameters to `PhoenixBottomNavigationBar` and place the Person item fourth. Set the Row to `.padding(horizontal = 8.dp)` and `Arrangement.spacedBy(4.dp)`. Set each weighted item's inner target to `.fillMaxWidth().heightIn(min = 48.dp)`; remove the 64dp `widthIn` constraint. + +Update the item API and modifier: + +```kotlin +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun PhoenixBottomNavigationItem( + icon: ImageVector, + contentDescription: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + onLongClick: (() -> Unit)? = null, + longClickLabel: String? = null, +) { + Box( + modifier = modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .clip(MaterialTheme.shapes.large) + .combinedClickable( + role = Role.Tab, + onClick = onClick, + onLongClick = onLongClick, + ) + .clearAndSetSemantics { + this.contentDescription = contentDescription + role = Role.Tab + this.selected = selected + this.onClick(label = contentDescription) { onClick(); true } + if (onLongClick != null && longClickLabel != null) { + this.onLongClick(label = longClickLabel) { onLongClick(); true } + } + }, + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(26.dp), + tint = iconColor, + ) + } +} +``` + +At the Profile call site: + +```kotlin +val haptic = LocalHapticFeedback.current + +PhoenixBottomNavigationItem( + icon = Icons.Default.Person, + contentDescription = stringResource(Res.string.cd_profile), + selected = currentRoute == NavigationRoutes.Profile.route, + onClick = onProfileClick, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onProfileLongClick() + }, + longClickLabel = stringResource(Res.string.cd_open_profile_switcher), + modifier = Modifier.weight(1f).testTag(TestTags.NAV_PROFILE), +) +``` + +A long press invokes only `onProfileLongClick`; it must not invoke `onProfileClick` or navigate. + +- [ ] **Step 7: Complete root-route visibility, selection, navigation, and titles** + +- Include `NavigationRoutes.Profile.route` in `shouldShowBottomBar`. +- Navigate on normal Profile tap with the same `popUpTo(Home) { saveState = true }`, `launchSingleTop`, and `restoreState` options as Analytics/Insights/Settings. +- In the Profile destination, capture `val profileTitle = stringResource(Res.string.nav_profile)` and call `LaunchedEffect(profileTitle) { viewModel.updateTopBarTitle(profileTitle) }`, matching Smart Insights. Also add `NavigationRoutes.Profile.route -> profileTitle` to `getScreenTitle` by passing that localized value into the helper; `getCompactScreenTitle` may return the already-localized full title for Profile. Do not add a hardcoded English route title. +- Add `NAV_PROFILE` and `SCREEN_PROFILE` to `TestTags`. + +- [ ] **Step 8: Run navigation contracts and compile the app shell** + +Run: + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest --tests "*ProfileNavigationContractTest*" --tests "*ProfileScreenContractTest*" :shared:compileKotlinIosArm64 :androidApp:assembleDebug --console=plain +``` + +Expected: BUILD SUCCESSFUL; the five-item route, pointer/semantic long press, sheet ownership, blocking context recovery, and Profile destination are compiled for Android and iOS. + +- [ ] **Step 9: Commit navigation and switcher wiring** + +```powershell +git add shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/navigation/NavigationRoutes.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/navigation/NavGraph.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/EnhancedMainScreen.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileDialogs.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/util/TestTags.kt shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/navigation/ProfileNavigationContractTest.kt +git commit -m "feat: add profile tab and long press switcher" +``` + +--- + +### Task 8: Move Typed Profile Preferences and Safety Controls onto ProfileScreen — Authoritative Contract + +> **Authoritative precedence:** This block replaces every earlier Task 8 draft. Execute only this contract. Task 9 removes the remaining Settings cards; it must not repeat the shared-dialog extraction completed here. + +**Files:** +- Create: shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfilePreferencePolicy.kt +- Create: shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfilePreferenceComponents.kt +- Create: shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileSafetyDialogs.kt +- Move: shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/AdultModePresentation.kt to shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/AdultModePresentation.kt +- Modify: shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileViewModel.kt +- Modify: shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ProfileScreen.kt +- Modify: shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/SettingsTab.kt +- Modify: shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/navigation/NavGraph.kt +- Modify: shared/src/commonMain/composeResources/values/strings.xml +- Modify: shared/src/commonMain/composeResources/values-de/strings.xml +- Modify: shared/src/commonMain/composeResources/values-es/strings.xml +- Modify: shared/src/commonMain/composeResources/values-fr/strings.xml +- Modify: shared/src/commonMain/composeResources/values-nl/strings.xml +- Modify: shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeUserProfileRepository.kt +- Modify: shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeExternalIntegrationRepositories.kt +- Modify: shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileViewModelTest.kt +- Modify: shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ProfileScreenContractTest.kt +- Create: shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/components/ProfilePreferencePolicyTest.kt +- Move: shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/AdultModePresentationTest.kt to shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/components/AdultModePresentationTest.kt +- Verify unchanged: shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/VbtEnabledRuntimeTest.kt +- Verify unchanged: shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/voice/SafeWordDetectionManagerTest.kt +- Verify unchanged: shared/src/commonTest/kotlin/com/devil/phoenixproject/data/preferences/VerbalEncouragementPreferenceCascadeTest.kt +- Verify unchanged: shared/src/commonTest/kotlin/com/devil/phoenixproject/data/preferences/ProfilePreferencesCodecTest.kt +- Verify unchanged: shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeExternalIntegrationRepositoriesTest.kt + +**Interfaces:** +- Consumes: Task 6's exact ProfileUiState, token-bearing ProfileIdentityMutation, scoped identity/recovery events, selectionFailure, single Channel-backed event collector, and exact ProfileScreen recovery API; Task 7's root Profile route and onOpenProfileSwitcher; UserProfileRepository whole-section writes and stale-active rejection; profile-aware Equipment Rack navigation; MainViewModel transient connection/disco APIs only. +- Produces: token/profile/section-owned preference writes; authoritative same-profile refresh before controls re-enable; generation-guarded body-weight attribution; compact localized cards; shared safety dialogs; and scoped post-commit adult/unlock outcomes. +- Preserves exactly: ProfileUiState.selectionFailure, ProfileUiState.identityMutation, ProfileUiState.identityMutationInFlight, every Task 6 identity/recovery event, ProfileScreen.onOpenProfileSwitcher, ProfileScreen.onNavigateToExerciseDetail, and ProfileScreen.onProfileRecoveryRequired. +- Does not change: typed schemas, repository signatures, ProfilePreferencesValidator, SettingsManager, runtime safety policy, or sync DTOs. Future non-negative LED indices remain codec-compatible and are normalized for display only. + +**Exact count contract:** +- ProfileViewModelTest begins at 26 Task 6 tests; add 20, final total 46. +- ProfileScreenContractTest begins at 7 Task 6 tests; add 8, final total 15. +- ProfilePreferencePolicyTest is new with 4 tests. +- Mandatory unchanged runtime set: VbtEnabledRuntimeTest 7, SafeWordDetectionManagerTest 2, VerbalEncouragementPreferenceCascadeTest 11, AdultModePresentationTest 3. +- Task 8 adds exactly 32 tests. The counted focused suite is 88 tests: 46 + 15 + 4 + 7 + 2 + 11 + 3. Codec and fake regressions run in addition. + +- [ ] **Step 1: Enforce the clean Task 6/7 baseline** + +Run only after Tasks 6 and 7 are committed in a clean isolated worktree: + +~~~powershell +if (git status --porcelain) { throw "Task 8 requires a clean worktree" } +$vm = (Select-String -Path shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileViewModelTest.kt -Pattern '^\s*@Test').Count +$screen = (Select-String -Path shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ProfileScreenContractTest.kt -Pattern '^\s*@Test').Count +if ($vm -ne 26 -or $screen -ne 7) { throw "Expected Task 6 counts 26/7, found $vm/$screen" } +rg -n "selectionFailure|identityMutation: ProfileIdentityMutation\?|ProfileRecoveryRequired|onProfileRecoveryRequired|onOpenProfileSwitcher" shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileViewModel.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ProfileScreen.kt +~~~ + +Expected: clean status, counts 26/7, and every preserved symbol present. Stop and reconcile earlier tasks on any mismatch. + +- [ ] **Step 2: Add deterministic fake seams and all failing tests** + +In FakeUserProfileRepository add: + +~~~kotlin +sealed interface PreferenceUpdateRequest { + val profileId: String + + data class Core( + override val profileId: String, + val value: CoreProfilePreferences, + ) : PreferenceUpdateRequest + + data class Rack( + override val profileId: String, + val value: RackPreferences, + ) : PreferenceUpdateRequest + + data class Workout( + override val profileId: String, + val value: WorkoutPreferences, + ) : PreferenceUpdateRequest + + data class Led( + override val profileId: String, + val value: LedPreferences, + ) : PreferenceUpdateRequest + + data class Vbt( + override val profileId: String, + val value: VbtPreferences, + ) : PreferenceUpdateRequest + + data class LocalSafety( + override val profileId: String, + val value: ProfileLocalSafetyPreferences, + ) : PreferenceUpdateRequest +} + +val preferenceUpdateRequests = mutableListOf() +var beforePreferenceUpdate: (suspend (PreferenceUpdateRequest) -> Unit)? = null +var updateCoreFailure: Throwable? = null +var updateRackFailure: Throwable? = null +var updateWorkoutFailure: Throwable? = null +var updateLedFailure: Throwable? = null +var updateVbtFailure: Throwable? = null +var updateLocalSafetyFailure: Throwable? = null +~~~ + +Each fake update appends its typed request, invokes beforePreferenceUpdate, throws its section failure if present, then runs the existing validated mutation. This order supports deterministic same-frame blocking, switching between adult writes, partial failure, and cancellation. + +In FakeExternalMeasurementRepository add: + +~~~kotlin +data class MeasurementObservationRequest( + val profileId: String, + val measurementType: String, +) + +val observationRequests = mutableListOf() +var observeByTypeOverride: + ((String, String) -> Flow>)? = null + +override fun observeMeasurementsByType( + profileId: String, + measurementType: String, +): Flow> { + observationRequests += MeasurementObservationRequest(profileId, measurementType) + return observeByTypeOverride?.invoke(profileId, measurementType) + ?: measurementsFlow.map { rows -> + rows.filter { + it.profileId == profileId && it.measurementType == measurementType + } + } +} +~~~ + +Add exactly these 20 ProfileViewModelTest cases: + +1. all six typed updates capture Ready ID and refresh authoritative sections before release; +2. same-section update rejects synchronously while another section proceeds; +3. preference and identity claims reject cross-domain overlap both directions; +4. ordinary failure is token/profile/section scoped; +5. stale A completion cannot clear a later owner or publish an unowned outcome; +6. same-profile Ready preserves preference owner and exercise insights; +7. Switching preserves ownership while clearing visible preference data; +8. measurement attribution restarts on same-profile Core generation/body-weight change; +9. measurement attribution clears during Switching; +10. non-cooperative old measurement cannot overwrite a new generation/profile; +11. adult enable owns LOCAL_SAFETY and VBT and commits safety first; +12. adult first-write failure commits neither section; +13. adult second-write failure retains confirmed safety and leaves vulgar false; +14. switch between adult writes never mutates B; +15. adult cancellation emits no terminal outcome and clears only its owner; +16. adult operation overlaps neither safety nor VBT writes; +17. adult partial-commit retry writes only VBT; +18. disco unlock succeeds only after authoritative matching commit; +19. dominatrix failure/switch produces no stale success; +20. decline writes prompted/unconfirmed and explicit enable remains retryable. + +Case 1 exercises every repository update. Case 5 uses a non-cooperative hook and compares tokens. Case 8 changes only Core and requires the old timestamp to clear without a new measurement emission. Case 13 asserts committedSections equals LOCAL_SAFETY. + +Add exactly these 8 ProfileScreenContractTest cases: + +1. complete preference resource inventory occurs once in all five locale files and visible copy is not hardcoded; +2. ProfileScreen retains one event collector and filters by current profile plus tracked token; +3. Profile route passes only transient MainViewModel actions and both navigation callbacks; +4. adult/unlock dialogs react only to matching post-commit outcomes; +5. Achievements precedes Preferences and is unconditional; +6. continuous sliders draft locally and commit only on onValueChangeFinished; +7. LED options use stable localized indices and radio semantics; +8. Settings delegates extracted dialogs and retains microphone disposal. + +The route test isolates only the NavigationRoutes.Profile destination. Reject MainViewModel.unlockDiscoMode and every persisted preference setter there. Assert EquipmentRack, Badges, exercise-detail, recovery, and switcher callbacks. + +Create ProfilePreferencePolicyTest with exactly four cases: newest matching imported weight; rejection of unset/wrong profile/provider/unit/type/tolerance; indices 0 through 7 remain stable; negative/future LED values display index 0 without storage writes. + +Move AdultModePresentationTest with its production file, changing package/imports only; retain its three tests. + +- [ ] **Step 3: Run the strict RED gate** + +~~~powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest --tests "com.devil.phoenixproject.presentation.viewmodel.ProfileViewModelTest" --tests "com.devil.phoenixproject.presentation.screen.ProfileScreenContractTest" --tests "com.devil.phoenixproject.presentation.components.ProfilePreferencePolicyTest" --rerun-tasks --console=plain +~~~ + +Expected: FAIL for missing mutation types/events, policy, components, resources, and route wiring. A pass means tests are not binding the contract. + +- [ ] **Step 4: Extend Task 6 state with token/profile/section ownership** + +Add beside the existing identity types: + +~~~kotlin +enum class ProfilePreferenceSection { + CORE, RACK, WORKOUT, LED, VBT, LOCAL_SAFETY, +} + +enum class ProfilePreferenceMutationKind { + UPDATE, ADULT_ENABLE, ADULT_DECLINE, DISCO_UNLOCK, DOMINATRIX_UNLOCK, +} + +data class ProfilePreferenceMutation( + val token: Long, + val profileId: String, + val sections: Set, + val kind: ProfilePreferenceMutationKind, +) +~~~ + +Do not replace ProfileUiState. Add only: + +~~~kotlin +val preferenceMutations: + Map = emptyMap(), +val importedBodyWeightMeasuredAt: Long? = null, +~~~ + +and: + +~~~kotlin +val busyPreferenceSections: Set + get() = preferenceMutations.keys +~~~ + +Keep selectionFailure and identityMutation unchanged. Preserve preferenceMutations beside identityMutation in Switching and different-Ready reconstruction. Same-profile Ready remains copy(context = context), preserving selection, insights, and both mutation domains. + +Replace only the placeholder PreferenceUpdateFailed object; retain all Task 6 events: + +~~~kotlin +data class PreferenceMutationSucceeded( + val profileId: String, + val token: Long, + val kind: ProfilePreferenceMutationKind, + val sections: Set, +) : ProfileUiEvent + +data class PreferenceUpdateFailed( + val profileId: String, + val token: Long, + val kind: ProfilePreferenceMutationKind, + val sections: Set, + val committedSections: Set, +) : ProfileUiEvent +~~~ + +Claim synchronously before launch: + +~~~kotlin +private fun claimPreferenceMutation( + sections: Set, + kind: ProfilePreferenceMutationKind, +): ProfilePreferenceMutation? { + require(sections.isNotEmpty()) + val profileId = currentMutationProfileId() ?: return null + val mutation = ProfilePreferenceMutation( + token = ++nextPreferenceToken, + profileId = profileId, + sections = sections, + kind = kind, + ) + while (true) { + val state = _uiState.value + val ready = state.context as? ActiveProfileContext.Ready ?: return null + if ( + state.identityMutation != null || + sections.any(state.preferenceMutations::containsKey) || + ready.profile.id != profileId || + currentMutationProfileId() != profileId + ) return null + + val claimed = state.copy( + preferenceMutations = state.preferenceMutations + + sections.associateWith { mutation }, + ) + if (_uiState.compareAndSet(state, claimed)) return mutation + } +} +~~~ + +Clear only matching token records: + +~~~kotlin +private fun clearPreferenceMutation(token: Long): Boolean { + var owned = false + _uiState.update { state -> + val retained = state.preferenceMutations.filterValues { + if (it.token == token) { + owned = true + false + } else { + true + } + } + if (owned) state.copy(preferenceMutations = retained) else state + } + return owned +} +~~~ + +Use CoroutineStart.LAZY. Each job follows this exact order: + +1. claim every required section with compareAndSet; +2. call repositories with mutation.profileId; +3. after each successful call add its section to committedSections; +4. read profiles.activeProfileContext.value and require Ready for the same ID; +5. apply that authoritative Ready before the next write and before releasing ownership; +6. rethrow CancellationException without an event; +7. map ordinary exceptions to the scoped failure; +8. in finally clear only the owning token, then send its terminal event only if it still owned the record; +9. return the token from accepted public calls, null from rejected calls. + +Every accepted mutation produces exactly one terminal event after owner-only clearing. Ordinary UPDATE success emits PreferenceMutationSucceeded too, so ProfileScreen can discard its tracked token without showing UI; it must not leave completed tokens resident in screen state. + +Rename currentIdentityProfileId to currentMutationProfileId and use it for both domains. Add state.preferenceMutations.isNotEmpty() to Task 6's identity claim; preference claims already reject identityMutation. This closes same-frame identity/delete versus preference races. + +Expose exact public APIs: + +~~~kotlin +fun updateCore(value: CoreProfilePreferences): Long? +fun updateRack(value: RackPreferences): Long? +fun updateWorkout(value: WorkoutPreferences): Long? +fun updateLed(value: LedPreferences): Long? +fun updateVbt(value: VbtPreferences): Long? +fun updateLocalSafety(value: ProfileLocalSafetyPreferences): Long? +fun confirmAdultsOnlyAndEnableVulgar(): Long? +fun declineAdultsOnly(): Long? +fun unlockDiscoMode(): Long? +fun unlockDominatrixMode(): Long? +~~~ + +Equipment Rack UI navigates rather than calling updateRack, but the complete typed facade remains tested. + +- [ ] **Step 5: Implement adult ordering, partial failure, and post-commit effects** + +confirmAdultsOnlyAndEnableVulgar takes no UI snapshots. + +- If consent is false, claim LOCAL_SAFETY plus VBT under one ADULT_ENABLE token. +- Write adultsOnlyConfirmed = true and adultsOnlyPrompted = true first. +- Refresh authoritative Ready, derive VBT from it, then set vulgarModeEnabled = true. +- First-write failure: committedSections empty; do not attempt VBT. +- Second-write failure/stale switch: keep confirmed/prompted safety, leave vulgar false, report committedSections = LOCAL_SAFETY, never roll consent back. +- If consent is already true and vulgar is false, retry by claiming/writing only VBT. +- declineAdultsOnly owns LOCAL_SAFETY only and writes confirmed false, prompted true. +- adultsOnlyPrompted prevents automatic prompting only. There is no automatic dialog effect; explicit locked/enable actions may reopen it. + +Disco and Dominatrix unlocks derive the latest authoritative section in ProfileViewModel. Dominatrix accepts only when verbal and vulgar intent are enabled, local consent is confirmed, and it is locked. Seven-tap counters reset when profile ID or eligibility changes. + +Sounds and unlock dialogs occur only after matching DISCO_UNLOCK or DOMINATRIX_UNLOCK success events. Click handlers never fire effects before persistence. General failures use the Profile snackbar; adult modal failures remain inline. + +- [ ] **Step 6: Add measurement generation and pure display policies** + +In ProfilePreferencePolicy.kt add: + +~~~kotlin +internal data class ProfileMeasurementKey( + val profileId: String, + val coreLocalGeneration: Long, + val bodyWeightKg: Float, +) + +internal fun normalizedLedSchemeIndex( + storedIndex: Int, + schemeCount: Int, +): Int = storedIndex.takeIf { it in 0 until schemeCount } ?: 0 + +internal fun latestImportedBodyWeightMeasuredAt( + profileId: String, + bodyWeightKg: Float, + measurements: List, +): Long? { + if (!bodyWeightKg.isFinite() || bodyWeightKg <= 0f) return null + return measurements.asSequence() + .filter { it.profileId == profileId } + .filter { it.measurementType == HealthBodyWeightSyncManager.MEASUREMENT_TYPE_WEIGHT } + .filter { it.unit == HealthBodyWeightSyncManager.UNIT_KG } + .filter { + it.provider == IntegrationProvider.APPLE_HEALTH || + it.provider == IntegrationProvider.GOOGLE_HEALTH + } + .filter { kotlin.math.abs(it.value - bodyWeightKg.toDouble()) < 0.05 } + .maxOfOrNull(ExternalBodyMeasurement::measuredAt) +} +~~~ + +ProfileViewModel owns measurementJob, currentMeasurementKey, currentMeasurementToken, and nextMeasurementToken. The key is profile ID, Core metadata.localGeneration, and bodyWeightKg. + +On every Ready, restart only when the key changes: increment token, cancel the old job, synchronously clear importedBodyWeightMeasuredAt, then observe the matching profile/type. Publication requires the current token/key, repository Ready ID, UI Ready ID, Core generation, and weight all still match. A profile-ID-only guard is forbidden. + +Switching increments the token before cancellation and clears key/timestamp while preserving mutation ownership. Thus same-profile body-weight changes invalidate immediately, and non-cooperative old flows cannot overwrite. + +Do not tighten the LED validator: codec tests intentionally preserve future non-negative values. Normalize only the displayed selection with normalizedLedSchemeIndex(stored, ColorSchemes.ALL.size); never auto-write index 0. + +- [ ] **Step 7: Build compact typed cards and final-value slider commits** + +Expose: + +~~~kotlin +@Composable +fun ProfilePreferenceSections( + profileId: String, + preferences: UserProfilePreferences, + localSafety: ProfileLocalSafetyPreferences, + importedBodyWeightMeasuredAt: Long?, + busySections: Set, + isConnected: Boolean, + discoModeActive: Boolean, + onCoreChange: (CoreProfilePreferences) -> Long?, + onWorkoutChange: (WorkoutPreferences) -> Long?, + onLedChange: (LedPreferences) -> Long?, + onVbtChange: (VbtPreferences) -> Long?, + onLocalSafetyChange: (ProfileLocalSafetyPreferences) -> Long?, + onRequestAdultsOnlyConfirmation: () -> Unit, + onUnlockDiscoMode: () -> Long?, + onUnlockDominatrixMode: () -> Long?, + onManageEquipmentRack: () -> Unit, + onDiscoModeToggle: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) +~~~ + +Render cards in fixed order: Measurements, Equipment Rack, Workout Behavior, LED, VBT, Safety. + +| Card | Exact typed behavior | +|---|---| +| Measurements | Weight unit; increment with -1f as Automatic; body weight entered in selected unit, LB converted with UnitConverter.lbToKg, stored only as 0f or 20f..300f kg. Blank does not silently clamp: Clear explicitly writes 0f; invalid input shows profile_body_weight_invalid. Dialog draft is keyed by profile ID, unit, and authoritative value. | +| Equipment Rack | Enabled/total summary plus navigation only; the rack screen owns RackPreferences writes. | +| Workout | summary -1/0/5..30, autostart 2..10, default rest 0 or 5..300, rep timing, auto-start routine, motion start, stop-at-top, stall detection, master beeps, spoken reps, countdown beeps, rep sound, gamification, weight suggestions, percent-of-PR toggle/50..120, voice stop. | +| VBT-owned default | defaultScalingBasis writes VbtPreferences, not WorkoutPreferences. | +| LED | Stable indices 0 blue, 1 green, 2 teal, 3 yellow, 4 pink, 5 red, 6 purple, 7 none; unlock is persisted, active Disco state is transient and connection-gated. | +| VBT | Master enabled, threshold 10..50, auto-end, verbal, vulgar intent/tier, Dominatrix unlock/active. | +| Safety | Phrase/calibration is one local-only write; changing phrase clears calibrated. Voice-stop intent remains synced WorkoutPreferences. | + +Only percent-of-PR and velocity threshold are continuous Sliders. Each uses rememberSaveable(profileId, authoritativeValue); onValueChange changes draft only; onValueChangeFinished sends exactly one final whole-section write; busy disables it; authoritative Ready resynchronizes it. Never write from Slider.onValueChange. Toggles/dropdowns rely on synchronous claims. + +Effective gating never rewrites stored intent: + +- VBT master off disables live subordinate controls while retaining/displaying values; insights remain visible. +- Auto-end additionally requires stall detection and visibly states that dependency. +- Turning verbal off clears vulgar and Dominatrix active in the same VBT copy. +- Turning vulgar off clears Dominatrix active in the same copy. +- Stored vulgar/Dominatrix intent without local consent remains visible but locked with an explicit Adults Only action. +- Explicit vulgar enable without consent opens confirmation regardless of adultsOnlyPrompted; no LaunchedEffect auto-prompt. +- Voice-stop may remain true while phrase is blank/uncalibrated; show settings_calibrate_first and calibration action. +- Achievements is outside this component and remains visible when gamification is off. + +All targets are at least 48.dp. LED uses selectableGroup and selectable with Role.RadioButton, selected semantics, and localized cd_select_led_scheme. Never display or branch on ColorScheme.name/scheme.name; index 7 means off. + +- [ ] **Step 8: Extract shared dialogs without duplicating or breaking Settings** + +Move AdultModePresentation and its test to presentation.components. Move SafeWordCalibrationDialog, AdultModeDialogCard, AdultModeActions/private visuals, DominatrixUnlockDialog, AdultsOnlyConfirmDialog, and DiscoModeUnlockDialog from SettingsTab into ProfileSafetyDialogs. Remove the original private definitions immediately; Settings imports/calls the shared versions until Task 9 removes its cards. + +At the temporary Settings call site pass isSubmitting = false and errorMessage = null while preserving its existing confirm, decline, and dismiss callbacks. This is the only compatibility adapter; there must be one implementation of each dialog. + +Expose exact signatures: + +~~~kotlin +@Composable +fun SafeWordCalibrationDialog( + safeWord: String, + onCalibrated: () -> Unit, + onDismiss: () -> Unit, +) + +@Composable +fun AdultsOnlyConfirmDialog( + isSubmitting: Boolean, + errorMessage: String?, + onConfirm: () -> Unit, + onDecline: () -> Unit, + onDismiss: () -> Unit, +) + +@Composable +fun DominatrixUnlockDialog(onDismiss: () -> Unit) + +@Composable +fun DiscoModeUnlockDialog(onDismiss: () -> Unit) +~~~ + +AdultsOnlyConfirmDialog disables confirm, decline, scrim/back dismiss while submitting and renders errorMessage inline. Close only on matching adult success. Preserve onDismiss when idle. + +SafeWordCalibrationDialog retains DisposableEffect(safeWord), startListening, stopListening in onDispose, listener clearing, three detections, mic error, and open-settings action. Do not copy the implementation. + +- [ ] **Step 9: Complete localization and LED accessibility inventory** + +Add these 22 keys exactly once to values, values-de, values-es, values-fr, and values-nl, with idiomatic translations rather than English copies: + +| Key | English source | +|---|---| +| profile_automatic | Automatic | +| profile_default_rest | Default rest | +| profile_master_beeps | Workout beeps | +| profile_rep_count_timing | Rep count timing | +| profile_body_weight_unset | Not set | +| profile_body_weight_invalid | Enter a body weight from 20 to 300 kg | +| profile_body_weight_imported | Matches an imported health measurement | +| profile_led_scheme_blue | Blue | +| profile_led_scheme_green | Green | +| profile_led_scheme_teal | Teal | +| profile_led_scheme_yellow | Yellow | +| profile_led_scheme_pink | Pink | +| profile_led_scheme_red | Red | +| profile_led_scheme_purple | Purple | +| profile_led_scheme_none | None | +| cd_select_led_scheme | Select LED scheme: %1$s | +| profile_disco_mode | Disco Mode | +| profile_disco_requires_connection | Connect to your trainer to use Disco Mode | +| profile_disco_unlocked_title | Disco Mode unlocked | +| profile_disco_unlocked_body | Turn on Disco Mode in LED preferences to make your trainer party. | +| profile_disco_unlocked_action | Let's party | +| profile_adult_enable_partial_failure | Age confirmation was saved, but Vulgar Mode could not be enabled. Try again. | + +Reuse existing settings_weight_unit, settings_weight_suggestions_title, safety/calibration, verbal/vulgar/Dominatrix/adult, Equipment Rack, cd_led_scheme, action_clear, and Task 3 profile keys. Master beeps and countdown beeps must have distinct labels. Default rest and rep timing must be labeled. Replace every hardcoded Disco dialog string. + +Map LED labels by the stable index list, never by ColorScheme.name. Contract tests parse all five XML files, require the identical 22-key set once per file, and reject old Disco literals plus scheme.name. + +- [ ] **Step 10: Extend the exact ProfileScreen API and preserve one event collector** + +Extend, do not replace, Task 6/7's signature: + +~~~kotlin +@Composable +fun ProfileScreen( + onOpenProfileSwitcher: () -> Unit, + onNavigateToExerciseDetail: (String) -> Unit, + onNavigateToEquipmentRack: () -> Unit, + onNavigateToBadges: () -> Unit, + onProfileRecoveryRequired: (ProfileContextRecoveryException) -> Unit, + isConnected: Boolean, + discoModeActive: Boolean, + onDiscoModeToggle: (Boolean) -> Unit, + onPlayDiscoUnlockSound: () -> Unit, + onPlayDominatrixUnlockSound: () -> Unit, + enableVideoPlayback: Boolean, + themeMode: ThemeMode, + modifier: Modifier = Modifier, + viewModel: ProfileViewModel = koinViewModel(), + exerciseRepository: ExerciseRepository = koinInject(), +) +~~~ + +Keep the existing single LaunchedEffect collector; a second collector is forbidden because Channel.receiveAsFlow would load-balance events. + +Track every accepted token by Ready profile ID. Reset pending tokens, adult target/error, tap counters, and unlock dialogs when profile changes. Act only when event.profileId is current and event.token remains tracked. + +- Preserve Task 6 identity/recovery branches. +- Generic UPDATE failure shows profile_update_failed once. +- Adult failure remains inline; committed LOCAL_SAFETY uses profile_adult_enable_partial_failure. +- Adult success closes its dialog. +- Disco/Dominatrix success plays sound and opens celebration only after matching token/profile commit. +- Failed/cancelled/untracked/stale outcomes have no effect. + +Adult isSubmitting reflects its tracked token; onDismiss is ignored while submitting. + +Ready list order is exactly: profile-header, exercise-insights, achievements, preferences-heading, profile-preferences. Achievements uses existing copy/TestTags.ACTION_BADGES, navigates only, and is unconditional. + +Wrap every ViewModel call to track its returned token. ProfileScreen never holds optimistic typed sections. + +- [ ] **Step 11: Wire navigation and transient MainViewModel actions only** + +Inside only NavigationRoutes.Profile: + +~~~kotlin +onNavigateToEquipmentRack = { + navController.navigate(NavigationRoutes.EquipmentRack.route) +}, +onNavigateToBadges = { + navController.navigate(NavigationRoutes.Badges.route) +}, +isConnected = connectionState is ConnectionState.Connected, +discoModeActive = discoModeActive, +onDiscoModeToggle = viewModel::toggleDiscoMode, +onPlayDiscoUnlockSound = viewModel::emitDiscoSound, +onPlayDominatrixUnlockSound = viewModel::emitDominatrixUnlockSound, +~~~ + +Preserve Task 6 exercise/recovery and Task 7 switcher callbacks. Collect connection/disco flows once. + +The Profile destination must not call MainViewModel.unlockDiscoMode or any persisted body-weight, rack, workout, LED, VBT, verbal, voice-stop, safe-word, or consent setter. Only toggleDiscoMode, emitDiscoSound, and emitDominatrixUnlockSound cross to MainViewModel. + +- [ ] **Step 12: Run exact count, focused, runtime-safety, Android, and iOS gates** + +Enforce counts: + +~~~powershell +$counts = @{'shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileViewModelTest.kt'=46;'shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ProfileScreenContractTest.kt'=15;'shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/components/ProfilePreferencePolicyTest.kt'=4;'shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/VbtEnabledRuntimeTest.kt'=7;'shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/voice/SafeWordDetectionManagerTest.kt'=2;'shared/src/commonTest/kotlin/com/devil/phoenixproject/data/preferences/VerbalEncouragementPreferenceCascadeTest.kt'=11;'shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/components/AdultModePresentationTest.kt'=3} +foreach($entry in $counts.GetEnumerator()){ $actual=(Select-String -Path $entry.Key -Pattern '^\s*@Test').Count; if($actual -ne $entry.Value){ throw "$($entry.Key): expected $($entry.Value), found $actual" } } +~~~ + +Run the counted 88 plus codec/fake regressions: + +~~~powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest --tests "com.devil.phoenixproject.presentation.viewmodel.ProfileViewModelTest" --tests "com.devil.phoenixproject.presentation.screen.ProfileScreenContractTest" --tests "com.devil.phoenixproject.presentation.components.ProfilePreferencePolicyTest" --tests "com.devil.phoenixproject.presentation.manager.VbtEnabledRuntimeTest" --tests "com.devil.phoenixproject.presentation.manager.VbtUiWiringTest" --tests "com.devil.phoenixproject.domain.voice.SafeWordDetectionManagerTest" --tests "com.devil.phoenixproject.data.preferences.VerbalEncouragementPreferenceCascadeTest" --tests "com.devil.phoenixproject.presentation.components.AdultModePresentationTest" --tests "com.devil.phoenixproject.data.preferences.ProfilePreferencesCodecTest" --tests "com.devil.phoenixproject.testutil.FakeExternalIntegrationRepositoriesTest" --rerun-tasks --console=plain +~~~ + +Expected: BUILD SUCCESSFUL. These unchanged runtime tests prove master-off VBT gating, calibrated local safe-word gating, consent-aware verbal routing, and adult presentation. + +Run targets separately: + +~~~powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:compileAndroidMain --rerun-tasks --console=plain +.\gradlew.bat '-Pskip.supabase.check=true' :shared:compileKotlinIosArm64 :shared:compileTestKotlinIosArm64 --rerun-tasks --console=plain +.\gradlew.bat '-Pskip.supabase.check=true' :androidApp:assembleDebug --rerun-tasks --console=plain +~~~ + +Expected: all BUILD SUCCESSFUL. Do not omit iOS test compilation. + +Run: + +~~~powershell +$forbidden = rg -n 'DISCO MODE UNLOCKED|Time to get funky|Toggle Disco Mode|Let.s Party|scheme\.name|ColorScheme\.name' shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfilePreferenceComponents.kt shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileSafetyDialogs.kt +if($LASTEXITCODE -eq 0){ throw "Hardcoded or unstable LED copy remains: $forbidden" } +if($LASTEXITCODE -gt 1){ throw "rg failed" } +~~~ + +Expected: no matches. + +- [ ] **Step 13: Enforce exact implementation scope and commit** + +Exact 21-path allowlist, counting both sides of two moves: + +~~~powershell +$allowed=@('shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfilePreferencePolicy.kt','shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfilePreferenceComponents.kt','shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileSafetyDialogs.kt','shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/AdultModePresentation.kt','shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/AdultModePresentation.kt','shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileViewModel.kt','shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ProfileScreen.kt','shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/SettingsTab.kt','shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/navigation/NavGraph.kt','shared/src/commonMain/composeResources/values/strings.xml','shared/src/commonMain/composeResources/values-de/strings.xml','shared/src/commonMain/composeResources/values-es/strings.xml','shared/src/commonMain/composeResources/values-fr/strings.xml','shared/src/commonMain/composeResources/values-nl/strings.xml','shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeUserProfileRepository.kt','shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeExternalIntegrationRepositories.kt','shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileViewModelTest.kt','shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ProfileScreenContractTest.kt','shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/components/ProfilePreferencePolicyTest.kt','shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/AdultModePresentationTest.kt','shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/components/AdultModePresentationTest.kt') +$changed=@(git diff --no-renames --name-only)+@(git ls-files --others --exclude-standard) +$unexpected=@($changed|Where-Object{$_ -notin $allowed}); if($unexpected){ throw "Unexpected paths: $($unexpected -join ', ')" } +git diff --check +git add -A -- $allowed +$staged=@(git diff --cached --no-renames --name-only); $missing=@($allowed|Where-Object{$_ -notin $staged}); $extra=@($staged|Where-Object{$_ -notin $allowed}); if($missing -or $extra){ throw "Staged scope mismatch missing=[$($missing -join ', ')] extra=[$($extra -join ', ')]" } +git diff --cached --check +git commit -m "feat: move typed preferences to profile" +~~~ + +Expected: exact 21-path implementation commit. The five files marked Verify unchanged remain byte-for-byte unchanged; the moved AdultModePresentationTest changes only path/package. + +Post-commit: + +~~~powershell +$committed=@(git show --no-renames --name-only --format= HEAD|Where-Object{$_}); $missing=@($allowed|Where-Object{$_ -notin $committed}); $extra=@($committed|Where-Object{$_ -notin $allowed}); if($missing -or $extra){ throw "Committed scope mismatch" } +if(git status --porcelain){ throw "Task 8 left a dirty worktree" } +~~~ + +Expected: exact scope and clean worktree. + +--- + +### Task 9: Prune Settings to Global App Configuration Only — Authoritative Contract + +**Exact implementation allowlist (five paths):** +- Modify: shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/SettingsTab.kt +- Modify: shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/navigation/NavGraph.kt +- Modify: shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/MainViewModel.kt +- Modify: shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/viewmodel/MainViewModelTest.kt +- Create: shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ProfileSettingsSeparationContractTest.kt + +**Verify unchanged:** SettingsManager, PreferencesManager, UserProfileRepository, every SQLDelight schema/migration, profile-preference codec and sync DTO/adapter/repository, ProfilePreferenceSections, ProfileSafetyDialogs, AdultModePresentation, ProfileScreen, ProfileNavigationContractTest, ProfileScreenContractTest, ProfileResourceContractTest, and all locale XML files. + +**Interfaces:** +- Consumes: Task 7's Profile route/rack/badges ownership and Task 8's ProfilePreferenceSections, shared ProfileSafetyDialogs, profile preference callbacks, and transient Disco/Dominatrix actions. +- Produces: a structurally global Settings route and SettingsTab, SettingsGlobalUiState sourced only from raw global preferences, a reduced MainViewModel write surface, and source contracts that prevent profile-owned controls from drifting back. +- Adds exactly nine tests: eight new ProfileSettingsSeparationContractTest cases plus one MainViewModelTest case. One existing MainViewModel test is rewritten without changing its count. + +**Boundary:** this task is presentation-only. SettingsManager remains the compatibility facade used by runtime managers and its tests. Do not remove its delegated profile setters. MainViewModel may retain profile-overlaid read APIs needed by workout consumers, but Settings and MainViewModel must no longer expose profile-owned writes. + +- [ ] **Step 1: Start clean and prove the Task 7/8 handoff** + +Run before editing: + +~~~powershell +if(git status --porcelain){ throw "Task 9 requires a clean worktree" } +$profile='shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ProfileScreen.kt' +$dialogs='shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileSafetyDialogs.kt' +$requiredProfile=@('ProfilePreferenceSections(','onNavigateToEquipmentRack','onNavigateToBadges','TestTags.ACTION_BADGES') +$requiredDialogs=@('fun SafeWordCalibrationDialog(','fun AdultsOnlyConfirmDialog(','fun DominatrixUnlockDialog(','fun DiscoModeUnlockDialog(','DisposableEffect(safeWord)') +foreach($symbol in $requiredProfile){ if(-not (Select-String -Quiet -LiteralPath $profile -SimpleMatch $symbol)){ throw "Task 8 Profile handoff missing: $symbol" } } +foreach($symbol in $requiredDialogs){ if(-not (Select-String -Quiet -LiteralPath $dialogs -SimpleMatch $symbol)){ throw "Task 8 dialog handoff missing: $symbol" } } +~~~ + +Expected: clean worktree and all handoff symbols present. Task 9 removes only Settings-owned modal state, imports, and call sites for the four shared dialogs. It must not edit or copy their implementations. + +Confirm inherited contract counts before Task 9: + +~~~powershell +$handoffCounts=@{ +'shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/navigation/ProfileNavigationContractTest.kt'=8 +'shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ProfileScreenContractTest.kt'=15 +'shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ProfileResourceContractTest.kt'=3 +} +foreach($entry in $handoffCounts.GetEnumerator()){ $actual=(Select-String -Path $entry.Key -Pattern '^\s*@Test').Count; if($actual -ne $entry.Value){ throw "$($entry.Key): expected $($entry.Value), found $actual" } } +~~~ + +- [ ] **Step 2: Create the exact eight-case separation contract** + +Create ProfileSettingsSeparationContractTest with exactly these eight tests: + +1. settingsTabExposesExactGlobalOnlySignature — extract the SettingsTab declaration and require the exact signature from Step 5, including no callback defaults and only modifier defaulting. +2. settingsContainsNoCanonicalProfileOwnedSymbolOrModalState — scan the bounded SettingsTab declaration/body against the complete Settings inventory below. +3. settingsRemovesOnlyTask8DialogCallSitesAndKeepsSharedDialogOwnership — require no shared-dialog call/import in Settings, require all four functions in ProfileSafetyDialogs, and require DisposableEffect(safeWord) there. +4. globalSectionsStayInRequiredOrderAndVideoUsesLocalizedResources — require the eight section groups in Step 7 in order, all three video string resources in Settings, and one declaration per key in each of the five existing locale XML files. +5. profileOwnsAchievementsRackAndPreferenceSectionsInRequiredOrder — require unconditional Achievements before Preferences, ProfilePreferenceSections, onNavigateToEquipmentRack, and no corresponding Settings card/callback. +6. settingsDestinationCollectsOnlyGlobalSettingsConnectionErrorAndBackupStats — extract only NavigationRoutes.Settings and require those three collected flows, the exact global callback wiring, and no canonical Settings/route forbidden symbol. +7. mainViewModelExposesNoProfileWriteWrappersAndKeepsRequiredReadAndTransientApis — reject the complete MainViewModel removal inventory, require private settingsManager, and retain the read/global/transient inventory in Step 4. +8. profileOwnsRackAndBadgesNavigationWhileDestinationsRemainUnique — require only the Profile destination to navigate to EquipmentRack and Badges, and require each destination registration exactly once. + +The canonical Settings/route forbidden inventory is one list; apply it to the SettingsTab signature/body and the bounded Settings destination: + +~~~text +weightUnit +onWeightUnitChange +weightIncrement +onWeightIncrementChange +bodyWeightKg +onBodyWeightKgChange +onNavigateToEquipmentRack +audioRepCountEnabled +onAudioRepCountChange +summaryCountdownSeconds +onSummaryCountdownChange +autoStartCountdownSeconds +onAutoStartCountdownChange +countdownBeepsEnabled +onCountdownBeepsChange +repSoundEnabled +onRepSoundChange +motionStartEnabled +onMotionStartChange +autoStartRoutine +onAutoStartRoutineChange +weightSuggestionsEnabled +onWeightSuggestionsEnabledChange +selectedColorSchemeIndex +onColorSchemeChange +discoModeUnlocked +discoModeActive +isConnected +onDiscoModeUnlocked +onDiscoModeToggle +onPlayDiscoSound +gamificationEnabled +onGamificationEnabledChange +voiceStopEnabled +onVoiceStopEnabledChange +safeWord +onSafeWordChange +safeWordCalibrated +onSafeWordCalibratedChange +velocityLossThresholdPercent +onVelocityLossThresholdChange +autoEndOnVelocityLoss +onAutoEndOnVelocityLossChange +stallDetectionEnabled +defaultScalingBasis +onDefaultScalingBasisChange +defaultRoutineExerciseUsePercentOfPR +onDefaultRoutineExerciseUsePercentOfPRChange +defaultRoutineExerciseWeightPercentOfPR +onDefaultRoutineExerciseWeightPercentOfPRChange +verbalEncouragementEnabled +onVerbalEncouragementEnabledChange +vulgarModeEnabled +onVulgarModeEnabledChange +vulgarTier +onVulgarTierChange +dominatrixModeUnlocked +onDominatrixModeUnlockedChange +dominatrixModeActive +onDominatrixModeActiveChange +adultsOnlyConfirmed +onConfirmAdultsAndEnableVulgar +adultsOnlyPrompted +onAdultsOnlyPromptedChange +onPlayDominatrixUnlockSound +onNavigateToBadges +UserProfileRepository +ExternalMeasurementRepository +SafeWordCalibrationDialog +AdultsOnlyConfirmDialog +DominatrixUnlockDialog +DiscoModeUnlockDialog +activeProfileId +healthBodyWeightMeasurements +latestMatchingHealthBodyWeight +localWeightUnit +showWeightIncrementDialog +showBodyWeightDialog +bodyWeightInput +localSafeWord +showCalibrationDialog +easterEggTapCount +lastTapTime +showDiscoUnlockDialog +dominatrixEasterEggTapCount +lastDominatrixTapTime +showDominatrixUnlockDialog +showAdultsOnlyDialog +onCancelAutoConnecting +~~~ + +onCancelAutoConnecting is dormant global plumbing rather than a profile preference, but it is forbidden by the same bounded surface contract because Settings never invokes it. + +The canonical MainViewModel profile-write removal inventory is: + +~~~text +setWeightUnit +setStopAtTop +setStallDetectionEnabled +setAudioRepCountEnabled +setRepCountTiming +setSummaryCountdownSeconds +setAutoStartCountdownSeconds +setColorScheme +setWeightIncrement +setAutoStartRoutine +setBodyWeightKg +setGamificationEnabled +setCountdownBeepsEnabled +setRepSoundEnabled +setMotionStartEnabled +setVoiceStopEnabled +setSafeWord +setSafeWordCalibrated +setVelocityLossThreshold +setAutoEndOnVelocityLoss +setWeightSuggestionsEnabled +setDefaultScalingBasis +setDefaultRoutineExerciseUsePercentOfPR +setDefaultRoutineExerciseWeightPercentOfPR +setVerbalEncouragementEnabled +setVulgarModeEnabled +setVulgarTier +setDominatrixModeUnlocked +setDominatrixModeActive +setAdultsOnlyConfirmed +confirmAdultsAndEnableVulgar +setAdultsOnlyPrompted +isAdultsOnlyPrompted +unlockDiscoMode +cancelAutoConnecting +~~~ + +Match function declarations or callable references, not comments or unrelated domain fields. The contract must also reject public val settingsManager. + +- [ ] **Step 3: Establish strict RED before production changes** + +Add the eight source-contract tests. In MainViewModelTest, rewrite the existing userPreferences updates when preferences change test to update the active profile's WorkoutPreferences through FakeUserProfileRepository.updateWorkout; do not call the soon-to-be-removed viewModel.setStopAtTop. This preserves profile-overlaid read coverage with no test-count change. + +Add one test named globalSettings ignores profile updates and emits global updates. It must: + +- collect the initial globalSettings value; +- update only the active profile's WorkoutPreferences through the fake repository and prove no globalSettings emission/value change; +- call setEnableVideoPlayback with the opposite value; +- require exactly the corresponding globalSettings emission. + +Before editing production, enforce eight and 33 tests, then run strict RED: + +~~~powershell +$redCounts=@{ +'shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ProfileSettingsSeparationContractTest.kt'=8 +'shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/viewmodel/MainViewModelTest.kt'=33 +} +foreach($entry in $redCounts.GetEnumerator()){ $actual=(Select-String -Path $entry.Key -Pattern '^\s*@Test').Count; if($actual -ne $entry.Value){ throw "$($entry.Key): expected $($entry.Value), found $actual" } } +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest --tests "com.devil.phoenixproject.presentation.screen.ProfileSettingsSeparationContractTest" --tests "com.devil.phoenixproject.presentation.viewmodel.MainViewModelTest" --rerun-tasks --console=plain +if($LASTEXITCODE -eq 0){ throw "Strict RED failed: production already satisfies the new contracts" } +~~~ + +Expected: FAIL because Settings still owns profile symbols and MainViewModel.globalSettings does not yet exist. Do not weaken assertions to obtain RED. + +- [ ] **Step 4: Add raw-global state and reduce MainViewModel's write boundary** + +Add this structurally global state in MainViewModel.kt: + +~~~kotlin +data class SettingsGlobalUiState( + val enableVideoPlayback: Boolean, + val bleCompatibilityMode: BleCompatibilitySetting, + val autoBackupEnabled: Boolean, + val backupDestination: BackupDestination, + val language: String, +) +~~~ + +Map directly from PreferencesManager.preferencesFlow, never from SettingsManager.userPreferences: + +~~~kotlin +private fun UserPreferences.toSettingsGlobalUiState() = SettingsGlobalUiState( + enableVideoPlayback = enableVideoPlayback, + bleCompatibilityMode = bleCompatibilityMode, + autoBackupEnabled = autoBackupEnabled, + backupDestination = backupDestination, + language = language, +) + +val globalSettings: StateFlow = + preferencesManager.preferencesFlow + .map(UserPreferences::toSettingsGlobalUiState) + .stateIn( + viewModelScope, + SharingStarted.Eagerly, + preferencesManager.preferencesFlow.value.toSettingsGlobalUiState(), + ) +~~~ + +Make settingsManager private. Remove every wrapper in the canonical MainViewModel removal inventory. In particular, remove dormant cancelAutoConnecting and persisted unlockDiscoMode; do not remove the underlying manager/repository APIs. + +Retain these MainViewModel boundaries: + +- Profile-overlaid reads needed by workout consumers: userPreferences, weightUnit, enableVideoPlayback, autoplayEnabled. +- Global Settings writes: setEnableVideoPlayback, setBleCompatibilityMode, setAutoBackupEnabled, setBackupDestination, setLanguage. +- Global Settings actions: deleteAllWorkouts, clearConnectionError, updateTopBarTitle, testSounds, refreshBackupStats, openBackupFolder. +- Task 8 transient hardware/actions: toggleDiscoMode, emitDiscoSound, emitDominatrixUnlockSound. +- Existing non-Settings APIs unrelated to this migration. + +The removed profile writes now flow exclusively through ProfileViewModel/UserProfileRepository. Do not delete SettingsManager compatibility setters because runtime managers and unchanged tests still depend on its active-profile facade. Keep velocityOneRepMaxBackfillDone internal and unrendered. + +- [ ] **Step 5: Replace SettingsTab with the exact global-only signature** + +Use exactly this public signature. No callback has a default; modifier is the only defaulted parameter. + +~~~kotlin +@Composable +fun SettingsTab( + enableVideoPlayback: Boolean, + themeMode: ThemeMode, + dynamicColorAvailable: Boolean, + dynamicColorEnabled: Boolean, + onEnableVideoPlaybackChange: (Boolean) -> Unit, + onThemeModeChange: (ThemeMode) -> Unit, + onDynamicColorEnabledChange: (Boolean) -> Unit, + onDeleteAllWorkouts: () -> Unit, + onNavigateToConnectionLogs: () -> Unit, + onNavigateToDiagnostics: () -> Unit, + onNavigateToLinkAccount: () -> Unit, + onNavigateToIntegrations: () -> Unit, + connectionError: String?, + onClearConnectionError: () -> Unit, + onSetTitle: (String) -> Unit, + onTestSounds: () -> Unit, + bleCompatibilityMode: BleCompatibilitySetting, + onBleCompatibilityModeChange: (BleCompatibilitySetting) -> Unit, + autoBackupEnabled: Boolean, + onAutoBackupEnabledChange: (Boolean) -> Unit, + backupStats: BackupStats?, + onOpenBackupFolder: () -> Unit, + backupDestination: BackupDestination, + onBackupDestinationChange: (BackupDestination) -> Unit, + selectedLanguage: String, + onLanguageChange: (String) -> Unit, + modifier: Modifier = Modifier, +) +~~~ + +Remove unused imports only after the body is pruned. Do not retain onCancelAutoConnecting in the signature or route. + +- [ ] **Step 6: Remove profile-owned Settings cards and modal ownership by symbol** + +Delete profile-owned UI using the canonical inventory and semantic card/state anchors, not historical line ranges. Remove: + +- body weight, weight unit/increment, Equipment Rack; +- workout timers, rep/audio/motion/auto-start/scaling/weight-suggestion defaults; +- LED scheme, Disco persisted unlock/taps/toggle; +- gamification and Achievements; +- voice-stop, safe word/calibration; +- VBT threshold/auto-end/stall controls; +- verbal encouragement, vulgar tier/consent, Dominatrix state/taps; +- every profile-only remember/derived/local state listed in the inventory. + +Task 8 already extracted dialog implementations. Remove only Settings imports, modal state, and call sites for SafeWordCalibrationDialog, AdultsOnlyConfirmDialog, DominatrixUnlockDialog, and DiscoModeUnlockDialog. Do not edit ProfileSafetyDialogs or AdultModePresentation and do not attempt to delete private implementations from Settings. + +Settings must have neither Equipment Rack nor Achievements cards/callbacks after pruning. + +- [ ] **Step 7: Retain global content in fixed order and add the localized video card** + +The final Settings visual order is exactly: + +1. donation/support; +2. Portal/cloud plus health/external integrations; +3. appearance/theme/dynamic color; +4. language; +5. video; +6. data management, backup/restore, and delete history; +7. BLE, logs, diagnostics, developer tools, and sound test; +8. app information/version. + +Preserve existing global behavior inside those groups. Do not render velocityOneRepMaxBackfillDone. Do not delete resources. + +Add these exact private components to SettingsTab.kt: + +~~~kotlin +@Composable +private fun GlobalSettingsSectionCard( + title: String, + icon: ImageVector, + content: @Composable ColumnScope.() -> Unit, +) { + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon(imageVector = icon, contentDescription = null) + Text(text = title, style = MaterialTheme.typography.titleMedium) + } + Spacer(modifier = Modifier.height(8.dp)) + content() + } + } +} + +@Composable +private fun GlobalSettingsSwitchRow( + title: String, + description: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .defaultMinSize(minHeight = 48.dp) + .toggleable( + value = checked, + role = Role.Switch, + onValueChange = onCheckedChange, + ) + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text(text = title, style = MaterialTheme.typography.bodyLarge) + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch(checked = checked, onCheckedChange = null) + } +} +~~~ + +Import ColumnScope, defaultMinSize, toggleable, Role, and ImageVector as needed. Parent toggleable semantics provide the checked state, Switch role, and a minimum 48dp target without a double callback. + +Place this card immediately after Language: + +~~~kotlin +GlobalSettingsSectionCard( + title = stringResource(Res.string.settings_video_behavior), + icon = Icons.Default.Tune, +) { + GlobalSettingsSwitchRow( + title = stringResource(Res.string.settings_show_exercise_videos), + description = stringResource( + Res.string.settings_show_exercise_videos_description, + ), + checked = enableVideoPlayback, + onCheckedChange = onEnableVideoPlaybackChange, + ) +} +~~~ + +The three video keys already exist exactly once in English, German, Spanish, French, and Dutch: + +~~~text +settings_video_behavior +settings_show_exercise_videos +settings_show_exercise_videos_description +~~~ + +Use them; do not edit locale XML. Remove the current hardcoded English video title/description. + +- [ ] **Step 8: Make the Settings destination consume global state only** + +Inside only NavigationRoutes.Settings, collect: + +~~~kotlin +val globalSettings by viewModel.globalSettings.collectAsState() +val connectionError by viewModel.connectionError.collectAsState() +val backupStats by viewModel.backupStats.collectAsState() +~~~ + +Keep the existing one-shot refreshBackupStats call. Pass SettingsTab values only from globalSettings or existing NavGraph global inputs: + +~~~kotlin +enableVideoPlayback = globalSettings.enableVideoPlayback, +themeMode = themeMode, +dynamicColorAvailable = dynamicColorAvailable, +dynamicColorEnabled = dynamicColorEnabled, +bleCompatibilityMode = globalSettings.bleCompatibilityMode, +autoBackupEnabled = globalSettings.autoBackupEnabled, +backupDestination = globalSettings.backupDestination, +selectedLanguage = globalSettings.language, +~~~ + +Wire only the exact SettingsTab callbacks in Step 5 to retained global MainViewModel actions, global theme/dynamic callbacks, or global destinations. The Settings destination must not collect weightUnit, userPreferences, connectionState, or discoModeActive and must not invoke any canonical profile-owned MainViewModel wrapper. + +Remove onNavigateToEquipmentRack, onNavigateToBadges, onCancelAutoConnecting, and all Task 8 transient/persisted profile wiring from Settings. + +- [ ] **Step 9: Preserve Profile and destination ownership** + +Do not edit ProfileScreen. The separation contract must prove its ready list remains: + +~~~text +profile-header +exercise-insights +achievements +preferences-heading +profile-preferences +~~~ + +Achievements is unconditional and appears before Preferences. Profile owns onNavigateToEquipmentRack and onNavigateToBadges. Only the Profile NavGraph destination supplies those callbacks. + +Do not add destination registrations. NavigationRoutes.Badges and NavigationRoutes.EquipmentRack must each be registered exactly once. Settings has neither navigation callback; Profile has both. + +- [ ] **Step 10: Prove data, migration, sync, and resources are untouched** + +This task must not modify: + +- SettingsManager.kt or PreferencesManager.kt; +- UserProfileRepository.kt or any profile preference repository; +- .sq/.sqm schema or migration files; +- ProfilePreferencesCodec, PortalSyncDtos/SyncModels, PortalSyncAdapter, PortalPullAdapter, or sync repositories; +- ProfilePreferenceSections, ProfileSafetyDialogs, AdultModePresentation, or ProfileScreen; +- any composeResources XML file. + +The exact five-path scope gate in Step 12 is authoritative. The unchanged regression classes in Step 11 additionally prove compatibility behavior, migration, persistence, codec, and sync remain intact. + +- [ ] **Step 11: Enforce exact 157-test count and run the focused/regression suite** + +Enforce these exact counts: + +~~~powershell +$counts=@{ +'shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ProfileSettingsSeparationContractTest.kt'=8 +'shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/viewmodel/MainViewModelTest.kt'=33 +'shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ProfileScreenContractTest.kt'=15 +'shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/navigation/ProfileNavigationContractTest.kt'=8 +'shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ProfileResourceContractTest.kt'=3 +'shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/manager/SettingsManagerTest.kt'=16 +'shared/src/commonTest/kotlin/com/devil/phoenixproject/data/preferences/SettingsPreferencesManagerTest.kt'=10 +'shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/migration/ProfilePreferencesMigrationTest.kt'=7 +'shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightProfilePreferencesRepositoryTest.kt'=8 +'shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/sync/SqlDelightProfilePreferenceSyncRepositoryTest.kt'=26 +'shared/src/commonTest/kotlin/com/devil/phoenixproject/data/preferences/ProfilePreferencesCodecTest.kt'=19 +'shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/ProfilePreferenceSyncDtosTest.kt'=4 +} +$total=0 +foreach($entry in $counts.GetEnumerator()){ $actual=(Select-String -Path $entry.Key -Pattern '^\s*@Test').Count; if($actual -ne $entry.Value){ throw "$($entry.Key): expected $($entry.Value), found $actual" }; $total += $actual } +if($total -ne 157){ throw "Expected 157 focused tests, found $total" } +~~~ + +Run all 157 with forced execution: + +~~~powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest --tests "com.devil.phoenixproject.presentation.screen.ProfileSettingsSeparationContractTest" --tests "com.devil.phoenixproject.presentation.viewmodel.MainViewModelTest" --tests "com.devil.phoenixproject.presentation.screen.ProfileScreenContractTest" --tests "com.devil.phoenixproject.presentation.navigation.ProfileNavigationContractTest" --tests "com.devil.phoenixproject.presentation.screen.ProfileResourceContractTest" --tests "com.devil.phoenixproject.presentation.manager.SettingsManagerTest" --tests "com.devil.phoenixproject.data.preferences.SettingsPreferencesManagerTest" --tests "com.devil.phoenixproject.data.migration.ProfilePreferencesMigrationTest" --tests "com.devil.phoenixproject.data.repository.SqlDelightProfilePreferencesRepositoryTest" --tests "com.devil.phoenixproject.data.sync.SqlDelightProfilePreferenceSyncRepositoryTest" --tests "com.devil.phoenixproject.data.preferences.ProfilePreferencesCodecTest" --tests "com.devil.phoenixproject.data.sync.ProfilePreferenceSyncDtosTest" --rerun-tasks --console=plain +~~~ + +Expected: BUILD SUCCESSFUL, exactly 157 counted tests. This includes the final Task 7 ProfileNavigationContractTest count of eight and Task 8 ProfileScreenContractTest count of fifteen. + +- [ ] **Step 12: Run target gates, enforce exact five-path scope, and commit** + +Run each target gate separately with forced execution: + +~~~powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:compileAndroidMain --rerun-tasks --console=plain +.\gradlew.bat '-Pskip.supabase.check=true' :shared:compileKotlinIosArm64 :shared:compileTestKotlinIosArm64 --rerun-tasks --console=plain +.\gradlew.bat '-Pskip.supabase.check=true' :androidApp:assembleDebug --rerun-tasks --console=plain +~~~ + +Expected: all three commands BUILD SUCCESSFUL. Android compilation, iOS main/test compilation, and the Android application assembly are separate mandatory gates. + +Enforce exact changed and staged scope with rename detection disabled: + +~~~powershell +$allowed=@( +'shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/SettingsTab.kt', +'shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/navigation/NavGraph.kt', +'shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/MainViewModel.kt', +'shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/viewmodel/MainViewModelTest.kt', +'shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ProfileSettingsSeparationContractTest.kt' +) +$changed=@((@(git diff --no-renames --name-only)+@(git ls-files --others --exclude-standard))|Sort-Object -Unique) +$missing=@($allowed|Where-Object{$_ -notin $changed}) +$extra=@($changed|Where-Object{$_ -notin $allowed}) +if($missing -or $extra){ throw "Changed scope mismatch missing=[$($missing -join ', ')] extra=[$($extra -join ', ')]" } +git diff --check +git add -A -- $allowed +$staged=@(git diff --cached --no-renames --name-only|Sort-Object -Unique) +$missing=@($allowed|Where-Object{$_ -notin $staged}) +$extra=@($staged|Where-Object{$_ -notin $allowed}) +if($missing -or $extra){ throw "Staged scope mismatch missing=[$($missing -join ', ')] extra=[$($extra -join ', ')]" } +git diff --cached --check +git commit -m "refactor: keep settings global only" +~~~ + +Post-commit, require the same exact five paths and a clean worktree: + +~~~powershell +$committed=@(git show --no-renames --name-only --format= HEAD|Where-Object{$_}|Sort-Object -Unique) +$missing=@($allowed|Where-Object{$_ -notin $committed}) +$extra=@($committed|Where-Object{$_ -notin $allowed}) +if($missing -or $extra){ throw "Committed scope mismatch missing=[$($missing -join ', ')] extra=[$($extra -join ', ')]" } +if(git status --porcelain){ throw "Task 9 left a dirty worktree" } +~~~ + +Expected: exact five-path implementation commit and clean worktree. + +--- + +### Task 10: Add the Verification-Only Sole Profile-Switcher Ownership Guard + +**Exact implementation allowlist (one path):** +- Modify: shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ProfileSettingsSeparationContractTest.kt + +**Verify unchanged:** every production file; ProfileNavigationContractTest; ProfileSwitcherViewModelTest; UserProfileRepository and its implementation/fakes; MigrationManager; DataBackupManager; all schema, resource, and platform files. + +**Interfaces:** +- Consumes: Task 7's atomic removal of both legacy selector call sites and four obsolete files, route-root ProfileSwitcherViewModel, and eight-case navigation contract; Task 8's callback-only Profile ownership; and Task 9's eight-case global/profile separation contract. +- Produces: one ninth separation-contract test proving Task-owned presentation surfaces did not acquire a second direct switch/create/recovery repository owner after Tasks 8–9. +- Adds exactly one test. Final focused counts are 9 ProfileSettingsSeparationContractTest + 8 ProfileNavigationContractTest + 17 ProfileSwitcherViewModelTest = 34. + +**Boundary:** this is a verification-only, test-only task. It performs no production cleanup and expects no RED phase because Task 7 already owns the removal. If a legacy symbol, deleted file, or second repository caller is found, stop and correct the Task 7, 8, or 9 owning commit before continuing. + +The presentation ownership chain remains: + +~~~text +Profile tab long press or ProfileScreen switch action + -> EnhancedMainScreen's single shared ProfileSwitcherSheet + -> route-root ProfileSwitcherViewModel + -> UserProfileRepository switch/create/recovery methods +~~~ + +Do not infer that a repository method is dead because it has one presentation owner. Preserve activeProfile, allProfiles, activeProfileContext, observePreferences, createProfile, createAndActivateProfile, updateProfile, deleteProfile, deleteActiveProfile, setActiveProfile, and reconcileActiveProfileContext. MigrationManager and DataBackupManager are valid non-presentation clients of activation/reconciliation APIs, so ownership scans must remain scoped to the presentation package. + +- [ ] **Step 1: Start clean and verify the inherited ownership/file inventory** + +Run only after Task 9 is committed: + +~~~powershell +if(git status --porcelain){ throw "Task 10 requires a clean worktree" } + +$counts=@{ +'shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ProfileSettingsSeparationContractTest.kt'=8 +'shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/navigation/ProfileNavigationContractTest.kt'=8 +'shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileSwitcherViewModelTest.kt'=17 +} +foreach($entry in $counts.GetEnumerator()){ $actual=(Select-String -Path $entry.Key -Pattern '^\s*@Test').Count; if($actual -ne $entry.Value){ throw "$($entry.Key): expected $($entry.Value), found $actual" } } + +$deleted=@( +'shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileSidePanel.kt', +'shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileSpeedDial.kt', +'shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/EditProfileDialog.kt', +'shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/DeleteProfileDialog.kt' +) +$present=@($deleted|Where-Object{Test-Path -LiteralPath $_}) +if($present){ throw "Task 7 deleted files returned: $($present -join ', ')" } + +$legacyPattern='\b(ProfileSidePanel|ProfileSpeedDial|EditProfileDialog|DeleteProfileDialog|AddProfileDialog|showAddProfileDialog)\b' +$legacy=@(rg -n $legacyPattern shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation -g '*.kt') +$legacyExit=$LASTEXITCODE +if($legacyExit -gt 1){ throw "Legacy rg failed with exit $legacyExit" } +if($legacy){ throw "Task 7 legacy symbol returned: $($legacy -join [Environment]::NewLine)" } + +$listItemPath='shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileListItem.kt' +$listItem=Get-Content -Raw -LiteralPath $listItemPath +foreach($symbol in @('onLongClick','combinedClickable','UserProfileRepository')){ if($listItem.Contains($symbol)){ throw "ProfileListItem retained legacy ownership: $symbol" } } + +$retained=@{ +'shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileIdentityComponents.kt'=@('ProfileColors','PROFILE_COLOR_COUNT','fun ProfileAvatar(') +'shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileDialogs.kt'=@('fun ProfileAddDialog(','fun ProfileEditDialog(','fun ProfileDeleteDialog(','fun ProfileRecoveryDialog(') +'shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileSwitcherSheet.kt'=@('fun ProfileSwitcherSheet(') +'shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileListItem.kt'=@('fun ProfileListItem(') +'shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileSwitcherViewModel.kt'=@('class ProfileSwitcherViewModel') +} +foreach($entry in $retained.GetEnumerator()){ + if(-not (Test-Path -LiteralPath $entry.Key)){ throw "Retained Task 7 file missing: $($entry.Key)" } + $source=Get-Content -Raw -LiteralPath $entry.Key + foreach($symbol in $entry.Value){ if(-not $source.Contains($symbol)){ throw "Retained symbol missing from $($entry.Key): $symbol" } } +} + +$owners=@(rg -l 'setActiveProfile\(|createAndActivateProfile\(|reconcileActiveProfileContext\(' shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation -g '*.kt') +$ownerExit=$LASTEXITCODE +if($ownerExit -gt 1){ throw "Owner rg failed with exit $ownerExit" } +$owners=@($owners|ForEach-Object{$_ -replace '\\','/'}|Sort-Object -Unique) +$expectedOwner='shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileSwitcherViewModel.kt' +if($owners.Count -ne 1 -or $owners[0] -ne $expectedOwner){ throw "Presentation repository owners=[$($owners -join ', ')]" } + +$repositoryPath='shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/UserProfileRepository.kt' +$repository=Get-Content -Raw -LiteralPath $repositoryPath +$requiredRepositoryApi=@('val activeProfile:','val allProfiles:','val activeProfileContext:','fun observePreferences(','suspend fun createProfile(','suspend fun createAndActivateProfile(','suspend fun updateProfile(','suspend fun deleteProfile(','suspend fun deleteActiveProfile(','suspend fun setActiveProfile(','suspend fun reconcileActiveProfileContext(') +foreach($api in $requiredRepositoryApi){ if(-not $repository.Contains($api)){ throw "Compatibility API missing: $api" } } +~~~ + +Expected: inherited counts 8/8/17, no legacy files/symbols, all extracted replacements present, ProfileListItem callback-only, ProfileSwitcherViewModel the sole presentation owner, and repository compatibility intact. This is a GREEN precondition, not a RED test. + +- [ ] **Step 2: Add the exact ninth separation-contract test** + +Add assertContains and assertEquals imports if Task 9 did not already need them. Append exactly this test to ProfileSettingsSeparationContractTest: + +~~~kotlin +@Test +fun profileSwitcherRemainsTheOnlyTaskOwnedPresentationRepositoryCaller() { + fun source(path: String): String = requireNotNull(readProjectFile(path), path) + + val switcherPath = + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileSwitcherViewModel.kt" + val mainPath = + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/EnhancedMainScreen.kt" + val justLiftPath = + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/JustLiftScreen.kt" + val graphPath = + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/navigation/NavGraph.kt" + val profilePath = + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ProfileScreen.kt" + val settingsPath = + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/SettingsTab.kt" + val mainViewModelPath = + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/MainViewModel.kt" + val sheetPath = + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileSwitcherSheet.kt" + val dialogsPath = + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileDialogs.kt" + val listItemPath = + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileListItem.kt" + + val switcher = source(switcherPath) + val main = source(mainPath) + val graph = source(graphPath) + val profile = source(profilePath) + val sheet = source(sheetPath) + val dialogs = source(dialogsPath) + val listItem = source(listItemPath) + + listOf( + "profiles.setActiveProfile(", + "profiles.createAndActivateProfile(", + "profiles.reconcileActiveProfileContext(", + ).forEach { call -> + assertEquals(1, Regex(Regex.escape(call)).findAll(switcher).count(), call) + } + + val directRepositoryCall = Regex( + """\b(?:profiles|profileRepository|userProfileRepository)\s*\.\s*""" + + """(?:setActiveProfile|createAndActivateProfile|reconcileActiveProfileContext)\s*\(""", + ) + mapOf( + mainPath to main, + justLiftPath to source(justLiftPath), + graphPath to graph, + profilePath to profile, + settingsPath to source(settingsPath), + mainViewModelPath to source(mainViewModelPath), + sheetPath to sheet, + dialogsPath to dialogs, + listItemPath to listItem, + ).forEach { (path, text) -> + assertFalse(directRepositoryCall.containsMatchIn(text), path) + } + + assertEquals(1, Regex("""\bProfileSwitcherSheet\s*\(""").findAll(main).count()) + assertContains(main, "profileSwitcherViewModel.openSwitcher()") + assertContains(graph, "onOpenProfileSwitcher = onOpenProfileSwitcher") + assertContains(profile, "onOpenProfileSwitcher") + assertFalse(sheet.contains("UserProfileRepository")) + assertFalse(dialogs.contains("UserProfileRepository")) + assertFalse(listItem.contains("UserProfileRepository")) + assertFalse(listItem.contains("onLongClick")) + assertFalse(listItem.contains("combinedClickable")) +} +~~~ + +The unchanged eighth ProfileNavigationContractTest case remains the durable guard for Enhanced Main, Just Lift, and the four deleted files. Do not duplicate those assertions here. + +- [ ] **Step 3: Enforce final 9/8/17 counts and run all 34 focused tests** + +Run: + +~~~powershell +$counts=@{ +'shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ProfileSettingsSeparationContractTest.kt'=9 +'shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/navigation/ProfileNavigationContractTest.kt'=8 +'shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileSwitcherViewModelTest.kt'=17 +} +$total=0 +foreach($entry in $counts.GetEnumerator()){ $actual=(Select-String -Path $entry.Key -Pattern '^\s*@Test').Count; if($actual -ne $entry.Value){ throw "$($entry.Key): expected $($entry.Value), found $actual" }; $total += $actual } +if($total -ne 34){ throw "Expected 34 ownership tests, found $total" } + +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest --tests "com.devil.phoenixproject.presentation.screen.ProfileSettingsSeparationContractTest" --tests "com.devil.phoenixproject.presentation.navigation.ProfileNavigationContractTest" --tests "com.devil.phoenixproject.presentation.viewmodel.ProfileSwitcherViewModelTest" --rerun-tasks --console=plain +~~~ + +Expected: BUILD SUCCESSFUL with exactly 34 tests and zero failures/errors/skips. A failure is an earlier-task regression; Task 10 does not authorize a production fix. + +- [ ] **Step 4: Run Android, iOS main/test, and application gates separately** + +Run: + +~~~powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:compileAndroidMain --rerun-tasks --console=plain +.\gradlew.bat '-Pskip.supabase.check=true' :shared:compileKotlinIosArm64 :shared:compileTestKotlinIosArm64 --rerun-tasks --console=plain +.\gradlew.bat '-Pskip.supabase.check=true' :androidApp:assembleDebug --rerun-tasks --console=plain +~~~ + +Expected: all three commands BUILD SUCCESSFUL. iOS test compilation is mandatory because the ninth test is in commonTest. + +- [ ] **Step 5: Enforce exact one-file scope and commit the guard** + +~~~powershell +$allowed=@( +'shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ProfileSettingsSeparationContractTest.kt' +) +$changed=@((@(git diff --no-renames --name-only)+@(git ls-files --others --exclude-standard))|Sort-Object -Unique) +$missing=@($allowed|Where-Object{$_ -notin $changed}) +$extra=@($changed|Where-Object{$_ -notin $allowed}) +if($missing -or $extra){ throw "Changed scope mismatch missing=[$($missing -join ', ')] extra=[$($extra -join ', ')]" } +git diff --check -- $allowed +git add -A -- $allowed +$staged=@(git diff --cached --no-renames --name-only|Sort-Object -Unique) +$missing=@($allowed|Where-Object{$_ -notin $staged}) +$extra=@($staged|Where-Object{$_ -notin $allowed}) +if($missing -or $extra){ throw "Staged scope mismatch missing=[$($missing -join ', ')] extra=[$($extra -join ', ')]" } +git diff --cached --check +git commit -m "test: guard sole profile switcher ownership" +~~~ + +Post-commit: + +~~~powershell +$committed=@(git show --no-renames --name-only --format= HEAD|Where-Object{$_}|Sort-Object -Unique) +$missing=@($allowed|Where-Object{$_ -notin $committed}) +$extra=@($committed|Where-Object{$_ -notin $allowed}) +if($missing -or $extra){ throw "Committed scope mismatch missing=[$($missing -join ', ')] extra=[$($extra -join ', ')]" } +if(git status --porcelain){ throw "Task 10 left a dirty worktree" } +~~~ + +Expected: one test-only path committed and a clean worktree. No production, repository, migration, resource, or platform file belongs to Task 10. + +--- + +### Task 11: Verify DI, Migrations, Cross-Target Compilation, and the Finished User Flows — Authoritative Final Contract + +> **Authoritative precedence:** This block replaces the removed historical Task 11 in commit `af421bec`. Execute it only after Tasks 8, 9, and 10 are committed. It is a verification-only final gate: it may not edit, format, stage, commit, deploy, or repair anything. + +**Files:** +- Verify only: `shared/src/commonMain/kotlin/com/devil/phoenixproject/di/DataModule.kt` +- Verify only: `shared/src/commonMain/kotlin/com/devil/phoenixproject/di/DomainModule.kt` +- Verify only: `shared/src/commonMain/kotlin/com/devil/phoenixproject/di/SyncModule.kt` +- Verify only: `shared/src/commonMain/kotlin/com/devil/phoenixproject/di/KoinInit.kt` +- Verify only: `shared/src/commonMain/kotlin/com/devil/phoenixproject/di/PresentationModule.kt` +- Verify only: `shared/src/androidMain/kotlin/com/devil/phoenixproject/di/PlatformModule.android.kt` +- Verify only: `shared/src/iosMain/kotlin/com/devil/phoenixproject/di/PlatformModule.ios.kt` +- Verify only: `shared/src/androidHostTest/kotlin/com/devil/phoenixproject/di/KoinModuleVerifyTest.kt` +- Verify only: schema 43, `42.sqm`, `MigrationStatements.kt`, `SchemaManifest.kt`, migration tests, profile preference sync code/tests, and the three tracked `docs/backend-handoff/profile-preference*` artifacts. +- Modify: none. + +**Interfaces:** +- Consumes: the complete data-foundation and sync/backend-handoff plans; Task 7's root switcher and 17/8/7 contracts; Task 8's 88 counted preference/UI/runtime tests; Task 9's global-only Settings contract; and Task 10's final 9/8/17 ownership guard. +- Produces: immutable evidence for schema and migration parity, DI, privacy, local-only ownership, the full shared suite, Android and iOS compilation, Android unit/lint/package gates, and realistic local emulator acceptance. +- Final count correction: Task 9's 157-test list becomes **158** after Task 10 changes `ProfileSettingsSeparationContractTest` from eight to nine. Task 10 remains a distinct **34-test** ownership gate: 9 + 8 + 17. + +**Non-negotiable boundary:** A failure belongs to the earlier task that owns the failing file or behavior. Stop, report `NOT READY`, and return it to that task. Task 11 never weakens an assertion, edits a source/test/plan file, runs a formatter, stages a repair, creates a commit, or creates an empty verification commit. + +Run every step in the same PowerShell session so `$task11StartHead` and `$task11Branch` remain available through the final immutable-state check. + +- [ ] **Step 1: Freeze the clean branch, immutable HEAD, and reviewed ranges** + +~~~powershell +$expectedBranch = 'codex/add-profile-tab' +$task11Branch = git branch --show-current +$task11StartHead = git rev-parse HEAD +if ($LASTEXITCODE -ne 0 -or -not $task11StartHead) { throw 'Could not capture Task 11 start HEAD' } +if ($task11Branch -ne $expectedBranch) { + throw "Task 11 requires $expectedBranch, found $task11Branch" +} + +$dirty = @(git status --porcelain=v1 --untracked-files=all) +if ($dirty.Count -ne 0) { + $dirty | ForEach-Object { Write-Host $_ } + throw 'Task 11 requires Tasks 8-10 committed and a clean worktree' +} +git diff --cached --quiet +if ($LASTEXITCODE -ne 0) { throw 'Task 11 requires an empty index' } + +foreach ($baseline in @('af421bec', '6ddf9031')) { + git rev-parse --verify "$baseline^{commit}" | Out-Null + if ($LASTEXITCODE -ne 0) { throw "Missing reviewed baseline $baseline" } + git merge-base --is-ancestor $baseline HEAD + if ($LASTEXITCODE -ne 0) { throw "$baseline is not an ancestor of HEAD" } +} + +"Task 11 branch=$task11Branch head=$task11StartHead" +"Feature range: af421bec..$task11StartHead" +"Sync range: 6ddf9031..$task11StartHead" +~~~ + +Expected: `codex/add-profile-tab`, both immutable baselines are ancestors, the index and worktree are empty, and no earlier task remains in progress. Do not continue from a dirty or detached checkout. + +- [ ] **Step 2: Enforce every hardened Task 7-10 count before running tests** + +~~~powershell +$finalCounts = [ordered]@{ +'shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileSwitcherViewModelTest.kt'=17 +'shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/navigation/ProfileNavigationContractTest.kt'=8 +'shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/components/ProfileIdentityPolicyTest.kt'=7 +'shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileViewModelTest.kt'=46 +'shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ProfileScreenContractTest.kt'=15 +'shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/components/ProfilePreferencePolicyTest.kt'=4 +'shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/VbtEnabledRuntimeTest.kt'=7 +'shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/voice/SafeWordDetectionManagerTest.kt'=2 +'shared/src/commonTest/kotlin/com/devil/phoenixproject/data/preferences/VerbalEncouragementPreferenceCascadeTest.kt'=11 +'shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/components/AdultModePresentationTest.kt'=3 +'shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ProfileSettingsSeparationContractTest.kt'=9 +'shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/viewmodel/MainViewModelTest.kt'=33 +'shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ProfileResourceContractTest.kt'=3 +'shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/manager/SettingsManagerTest.kt'=16 +'shared/src/commonTest/kotlin/com/devil/phoenixproject/data/preferences/SettingsPreferencesManagerTest.kt'=10 +'shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/migration/ProfilePreferencesMigrationTest.kt'=7 +'shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightProfilePreferencesRepositoryTest.kt'=8 +'shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/sync/SqlDelightProfilePreferenceSyncRepositoryTest.kt'=26 +'shared/src/commonTest/kotlin/com/devil/phoenixproject/data/preferences/ProfilePreferencesCodecTest.kt'=19 +'shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/ProfilePreferenceSyncDtosTest.kt'=4 +'shared/src/androidHostTest/kotlin/com/devil/phoenixproject/di/KoinModuleVerifyTest.kt'=1 +} +foreach ($entry in $finalCounts.GetEnumerator()) { + if (-not (Test-Path -LiteralPath $entry.Key)) { throw "Missing counted suite $($entry.Key)" } + $actual = (Select-String -Path $entry.Key -Pattern '^\s*@Test').Count + if ($actual -ne $entry.Value) { + throw "$($entry.Key): expected $($entry.Value), found $actual" + } +} +$uniqueTotal = ($finalCounts.Values | Measure-Object -Sum).Sum +if ($uniqueTotal -ne 256) { throw "Expected 256 unique counted tests, found $uniqueTotal" } + +$task8Total = 46 + 15 + 4 + 7 + 2 + 11 + 3 +$task9AfterTask10Total = 9 + 33 + 15 + 8 + 3 + 16 + 10 + 7 + 8 + 26 + 19 + 4 +$task10Total = 9 + 8 + 17 +if ($task8Total -ne 88) { throw "Expected Task 8 count 88, found $task8Total" } +if ($task9AfterTask10Total -ne 158) { + throw "Expected post-Task-10 Task 9 count 158, found $task9AfterTask10Total" +} +if ($task10Total -ne 34) { throw "Expected Task 10 count 34, found $task10Total" } + +function Assert-CleanJUnit( + [string]$label, + [Nullable[int]]$expectedTotal = $null, + [bool]$allowSkipped = $false +) { + $files = @(Get-ChildItem 'shared/build/test-results/testAndroidHostTest' -Filter 'TEST-*.xml') + if ($files.Count -eq 0) { throw "$label produced no JUnit XML" } + $suites = $files | ForEach-Object { [xml](Get-Content -Raw $_.FullName) } + $tests = ($suites | ForEach-Object { [int]$_.testsuite.tests } | Measure-Object -Sum).Sum + $failures = ($suites | ForEach-Object { + [int]$_.testsuite.failures + [int]$_.testsuite.errors + } | Measure-Object -Sum).Sum + $skipped = ($suites | ForEach-Object { [int]$_.testsuite.skipped } | Measure-Object -Sum).Sum + if ($failures -ne 0 -or (-not $allowSkipped -and $skipped -ne 0)) { + throw "${label}: tests=$tests failures/errors=$failures skipped=$skipped" + } + if ($null -ne $expectedTotal -and $tests -ne $expectedTotal) { + throw "${label}: expected $expectedTotal tests, found $tests" + } + Write-Host "${label}: $tests tests, failures/errors=$failures skipped=$skipped" + return $tests +} +~~~ + +Expected: all 21 source counts match, their unique sum is 256, Task 8 is 88, the post-Task-10 Task 9 list is 158, Task 10 is 34, and the reusable XML verifier is loaded for later steps. + +- [ ] **Step 3: Run the exact hardened UI, preference, Settings, and ownership suites** + +Run each command with forced execution, then inspect the XML before starting the next command: + +~~~powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest --tests "com.devil.phoenixproject.presentation.viewmodel.ProfileSwitcherViewModelTest" --tests "com.devil.phoenixproject.presentation.navigation.ProfileNavigationContractTest" --tests "com.devil.phoenixproject.presentation.components.ProfileIdentityPolicyTest" --rerun-tasks --console=plain +if ($LASTEXITCODE -ne 0) { throw 'Task 7 final suite failed' } +Assert-CleanJUnit 'Task 7 final suite' 32 + +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest --tests "com.devil.phoenixproject.presentation.viewmodel.ProfileViewModelTest" --tests "com.devil.phoenixproject.presentation.screen.ProfileScreenContractTest" --tests "com.devil.phoenixproject.presentation.components.ProfilePreferencePolicyTest" --tests "com.devil.phoenixproject.presentation.manager.VbtEnabledRuntimeTest" --tests "com.devil.phoenixproject.presentation.manager.VbtUiWiringTest" --tests "com.devil.phoenixproject.domain.voice.SafeWordDetectionManagerTest" --tests "com.devil.phoenixproject.data.preferences.VerbalEncouragementPreferenceCascadeTest" --tests "com.devil.phoenixproject.presentation.components.AdultModePresentationTest" --tests "com.devil.phoenixproject.data.preferences.ProfilePreferencesCodecTest" --tests "com.devil.phoenixproject.testutil.FakeExternalIntegrationRepositoriesTest" --rerun-tasks --console=plain +if ($LASTEXITCODE -ne 0) { throw 'Task 8 final suite failed' } +Assert-CleanJUnit 'Task 8 counted plus codec/fake suite' 109 + +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest --tests "com.devil.phoenixproject.presentation.screen.ProfileSettingsSeparationContractTest" --tests "com.devil.phoenixproject.presentation.viewmodel.MainViewModelTest" --tests "com.devil.phoenixproject.presentation.screen.ProfileScreenContractTest" --tests "com.devil.phoenixproject.presentation.navigation.ProfileNavigationContractTest" --tests "com.devil.phoenixproject.presentation.screen.ProfileResourceContractTest" --tests "com.devil.phoenixproject.presentation.manager.SettingsManagerTest" --tests "com.devil.phoenixproject.data.preferences.SettingsPreferencesManagerTest" --tests "com.devil.phoenixproject.data.migration.ProfilePreferencesMigrationTest" --tests "com.devil.phoenixproject.data.repository.SqlDelightProfilePreferencesRepositoryTest" --tests "com.devil.phoenixproject.data.sync.SqlDelightProfilePreferenceSyncRepositoryTest" --tests "com.devil.phoenixproject.data.preferences.ProfilePreferencesCodecTest" --tests "com.devil.phoenixproject.data.sync.ProfilePreferenceSyncDtosTest" --rerun-tasks --console=plain +if ($LASTEXITCODE -ne 0) { throw 'Post-Task-10 Settings regression suite failed' } +Assert-CleanJUnit 'Post-Task-10 Settings regression suite' 158 + +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest --tests "com.devil.phoenixproject.presentation.screen.ProfileSettingsSeparationContractTest" --tests "com.devil.phoenixproject.presentation.navigation.ProfileNavigationContractTest" --tests "com.devil.phoenixproject.presentation.viewmodel.ProfileSwitcherViewModelTest" --rerun-tasks --console=plain +if ($LASTEXITCODE -ne 0) { throw 'Task 10 ownership suite failed' } +Assert-CleanJUnit 'Task 10 ownership suite' 34 +~~~ + +Expected: 32, 109, 158, and 34 tests respectively, each with zero failures, errors, or skips. Wildcard `*Profile*` is forbidden because it cannot prove the hardened suite inventory. + +- [ ] **Step 4: Regenerate SQLDelight and prove the exact schema-43/42.sqm migration boundary** + +~~~powershell +$schemaOutput = & .\gradlew.bat '-Pskip.supabase.check=true' :shared:generateCommonMainVitruvianDatabaseInterface :shared:verifyCommonMainVitruvianDatabaseMigration :shared:validateSchemaManifest --rerun-tasks --console=plain 2>&1 +$schemaExit = $LASTEXITCODE +$schemaOutput | ForEach-Object { Write-Host $_ } +if ($schemaExit -ne 0) { throw 'SQLDelight generation/migration/manifest gate failed' } +$schemaText = $schemaOutput -join [Environment]::NewLine +if ($schemaText -notmatch 'Schema manifest validated: 389 columns across 46 tables, all covered[.]') { + throw 'Expected the reviewed 389-column/46-table schema manifest result' +} + +$sharedBuild = Get-Content -Raw 'shared/build.gradle.kts' +if ($sharedBuild -notmatch 'version\s*=\s*43') { throw 'SQLDelight schema version is not 43' } +$migrationPath = 'shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/migrations/42.sqm' +git ls-files --error-unmatch -- $migrationPath | Out-Null +if ($LASTEXITCODE -ne 0) { throw 'Tracked 42.sqm migration is missing' } +$migration = Get-Content -Raw -LiteralPath $migrationPath +$fallback = Get-Content -Raw 'shared/src/commonMain/kotlin/com/devil/phoenixproject/data/local/MigrationStatements.kt' +foreach ($token in @( + 'CREATE TABLE UserProfilePreferences', + 'CREATE TABLE PendingProfileLocalCleanup', + 'CREATE TABLE PendingProfileContextRecovery', + 'INSERT OR IGNORE INTO UserProfilePreferences(profile_id)', + 'SELECT id FROM UserProfile' +)) { + if (-not $migration.Contains($token)) { throw "42.sqm missing $token" } + if (-not $fallback.Contains($token)) { throw "MigrationStatements fallback missing $token" } +} + +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest --tests "com.devil.phoenixproject.data.local.SchemaParityTest" --tests "com.devil.phoenixproject.data.local.SchemaManifestTest" --tests "com.devil.phoenixproject.data.migration.ProfilePreferencesMigrationTest" --tests "com.devil.phoenixproject.data.migration.MigrationManagerTest" --rerun-tasks --console=plain +if ($LASTEXITCODE -ne 0) { throw 'Schema parity or required-migration suite failed' } +Assert-CleanJUnit 'Schema and required-migration suite' +~~~ + +Expected: schema version 43, tracked `42.sqm`, the same three-table/seed boundary in `42.sqm` and the fallback, manifest output 389/46, and clean parity, legacy-copy, retry, reconciliation, and awaited boot-gate tests. The canonical schema remains seed-free at creation time; the parity tests are authoritative for executable DDL equality. + +- [ ] **Step 5: Run the exact mobile sync/backend-handoff, repository-race, privacy, and DI gates** + +This is local mobile-repository verification only. Every command retains `-Pskip.supabase.check=true`. + +~~~powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest --tests "com.devil.phoenixproject.data.sync.BackendHandoffContractTest" --tests "com.devil.phoenixproject.data.sync.ProfilePreferenceSyncDtosTest" --tests "com.devil.phoenixproject.data.sync.ProfilePreferenceSyncPlannerTest" --tests "com.devil.phoenixproject.data.sync.PortalSyncAdapterProfilePreferencesTest" --tests "com.devil.phoenixproject.data.sync.PortalPullAdapterProfilePreferencesTest" --tests "com.devil.phoenixproject.data.sync.PortalApiClientProfilePreferenceLimitsTest" --tests "com.devil.phoenixproject.testutil.FakePortalApiClientTest" --tests "com.devil.phoenixproject.data.sync.ErrorClassificationTest" --tests "com.devil.phoenixproject.data.sync.ClientRateLimiterTest" --tests "com.devil.phoenixproject.data.sync.SyncManagerProfilePreferencesTest" --tests "com.devil.phoenixproject.data.sync.PortalPushLimitsTest" --tests "com.devil.phoenixproject.data.sync.PortalPullPaginationTest" --tests "com.devil.phoenixproject.data.sync.PortalTokenRefreshTest" --tests "com.devil.phoenixproject.data.sync.SyncManagerTest" --tests "com.devil.phoenixproject.data.sync.SyncInvariantCheckerTest" --rerun-tasks --console=plain +if ($LASTEXITCODE -ne 0) { throw 'Focused sync/transport/privacy suite failed' } +$syncFocusedTotal = Assert-CleanJUnit 'Focused sync/transport/privacy suite' +if ($syncFocusedTotal -le 0) { throw 'Focused sync suite executed zero tests' } + +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest --tests "com.devil.phoenixproject.data.sync.SqlDelightProfilePreferenceSyncRepositoryTest" --tests "com.devil.phoenixproject.data.repository.SqlDelightProfilePreferencesRepositoryTest" --tests "com.devil.phoenixproject.data.migration.ProfilePreferencesMigrationTest" --tests "com.devil.phoenixproject.data.migration.MigrationManagerTest" --rerun-tasks --console=plain +if ($LASTEXITCODE -ne 0) { throw 'Repository race/migration-gate suite failed' } +Assert-CleanJUnit 'Repository race/migration-gate suite' + +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest --tests "com.devil.phoenixproject.di.KoinModuleVerifyTest" --rerun-tasks --console=plain +if ($LASTEXITCODE -ne 0) { throw 'Koin graph verification failed' } +Assert-CleanJUnit 'Koin graph verification' 1 +~~~ + +Run the local-only handoff and privacy inventory: + +~~~powershell +$handoffFiles = @( +'docs/backend-handoff/profile-preferences-supabase.sql', +'docs/backend-handoff/profile-preferences-edge-functions.md', +'docs/backend-handoff/profile-preference-byte-goldens.json' +) +foreach ($path in $handoffFiles) { + git ls-files --error-unmatch -- $path | Out-Null + if ($LASTEXITCODE -ne 0) { throw "Missing tracked backend handoff artifact $path" } +} +$handoffTest = Get-Content -Raw 'shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/BackendHandoffContractTest.kt' +$pinnedDigestMatch = [regex]::Match( + $handoffTest, + 'EXPECTED_EDGE_HANDOFF_SHA256\s*=\s*"(?[0-9a-f]{64})"' +) +if (-not $pinnedDigestMatch.Success) { + throw 'Backend handoff test does not pin one exact SHA-256 digest' +} +$pinnedDigest = $pinnedDigestMatch.Groups['digest'].Value +$emptyDigest = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' +if ($pinnedDigest -eq $emptyDigest) { + throw 'Backend handoff still uses the empty-string digest sentinel' +} + +$diPaths = @( + 'shared/src/commonMain/kotlin/com/devil/phoenixproject/di/DataModule.kt', + 'shared/src/commonMain/kotlin/com/devil/phoenixproject/di/DomainModule.kt', + 'shared/src/commonMain/kotlin/com/devil/phoenixproject/di/SyncModule.kt', + 'shared/src/commonMain/kotlin/com/devil/phoenixproject/di/PresentationModule.kt' +) +$diText = @($diPaths | ForEach-Object { Get-Content -Raw -LiteralPath $_ }) -join [Environment]::NewLine +foreach ($binding in @( + 'single', + 'single', + 'single', + 'single', + 'single', + 'ProfilePreferenceSyncCodec()', + 'ProfileViewModel(', + 'ProfileSwitcherViewModel(profiles = get())' +)) { + if (-not $diText.Contains($binding)) { throw "Required DI binding missing: $binding" } +} +$settingsManagerBinding = @(rg -n 'single|factory\s*\{\s*SettingsManager' shared/src/commonMain/kotlin/com/devil/phoenixproject/di -g '*.kt') +if ($LASTEXITCODE -gt 1) { throw 'SettingsManager DI scan failed' } +if ($settingsManagerBinding) { throw 'SettingsManager must remain MainViewModel-owned, not a singleton/factory' } + +$ownerFiles = @(rg -l 'setActiveProfile\(|createAndActivateProfile\(|reconcileActiveProfileContext\(' shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation -g '*.kt') +if ($LASTEXITCODE -gt 1) { throw 'Presentation ownership scan failed' } +$ownerFiles = @($ownerFiles | ForEach-Object { $_ -replace '\\','/' } | Sort-Object -Unique) +$expectedOwner = 'shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileSwitcherViewModel.kt' +if ($ownerFiles.Count -ne 1 -or $ownerFiles[0] -ne $expectedOwner) { + throw "Presentation switch/create/reconcile owners=[$($ownerFiles -join ', ')]" +} + +$deleted = @( +'shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileSidePanel.kt', +'shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileSpeedDial.kt', +'shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/EditProfileDialog.kt', +'shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/DeleteProfileDialog.kt' +) +if (@($deleted | Where-Object { Test-Path -LiteralPath $_ }).Count -ne 0) { + throw 'A Task 7 legacy selector file returned' +} +$legacy = @(rg -n '\b(ProfileSidePanel|ProfileSpeedDial|EditProfileDialog|DeleteProfileDialog|AddProfileDialog|showAddProfileDialog)\b' shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation -g '*.kt') +if ($LASTEXITCODE -gt 1) { throw 'Legacy symbol scan failed' } +if ($legacy) { throw "Legacy selector symbols remain: $($legacy -join [Environment]::NewLine)" } + +$featureSurfaces = @( +'shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components', +'shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ProfileScreen.kt', +'shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/SettingsTab.kt', +'shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/navigation/NavGraph.kt', +'shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileViewModel.kt', +'shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileSwitcherViewModel.kt', +'shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync' +) +$unfinished = @(rg -n 'TODO|TBD|NotImplementedError|error\("placeholder' $featureSurfaces -g 'Profile*.kt' -g 'SettingsTab.kt' -g 'NavGraph.kt') +if ($LASTEXITCODE -gt 1) { throw 'Feature unfinished-marker scan failed' } +if ($unfinished) { throw "Feature-owned unfinished marker remains: $($unfinished -join [Environment]::NewLine)" } + +$oneArgumentWrites = @(rg -n 'profiles\.update(Core|Rack|Workout|Led|Vbt|LocalSafety)\([^,\r\n]+\)' shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation -g '*.kt') +if ($LASTEXITCODE -gt 1) { throw 'Explicit profile-ID write scan failed' } +if ($oneArgumentWrites) { throw 'A profile preference write omitted its explicit profile ID' } + +$mobileSecretHits = @(rg -n 'SUPABASE_SERVICE_ROLE_KEY|serviceRoleKey' shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync -g '*.kt') +if ($LASTEXITCODE -gt 1) { throw 'Mobile service-role scan failed' } +if ($mobileSecretHits) { throw 'A service-role secret identifier entered mobile sync code' } + +$portalTargets = @(git diff --name-only '6ddf9031..HEAD' | Where-Object { $_ -match '^supabase/' }) +if ($portalTargets) { throw "Mobile feature range contains portal targets: $($portalTargets -join ', ')" } +~~~ + +Expected: one Koin test, all required focused-store/gate/sync/root-ViewModel bindings, no `SettingsManager` application binding or DI cycle, one presentation switch/create/reconcile owner, no legacy selectors, no feature-owned unfinished marker, explicit profile IDs on preference writes, a non-sentinel sealed backend handoff, no mobile service-role secret identifier, and no `supabase/` path. + +The contract tests are authoritative for the remaining privacy invariants: exactly five sync sections; `localGeneration` never serialized; safe word, calibration, and adult consent/prompt values appear only in the approved normalized rejection denylists and never in sync/backup values; preference-only payloads contain no active-profile metadata or ordinary entities; pull never creates identities; and diagnostics contain fixed categories/counts rather than raw identifiers, values, payloads, or exception messages. + +- [ ] **Step 6: Enforce the strict no-remote boundary** + +Task 11 must not invoke or install a Supabase CLI; use a Supabase MCP/project integration or Dashboard; authenticate to a portal; execute remote SQL; run `supabase start`, `db reset`, `db push`, migration list/repair, lint, or advisors; create a remote migration; deploy an Edge Function; or modify any portal checkout, remote branch, database object, or external state. + +The `External Portal Handoff Acceptance Checklist` in `2026-07-11-profile-preferences-sync-backend.md` remains documentation for a separately authorized portal owner. It is not Task 11. The mobile handoff may be locally verified while remote deployment remains an explicit release-enablement dependency. + +Expected: all verification remains local and every Gradle command uses quoted `'-Pskip.supabase.check=true'`. Record in the final handoff: `No Supabase CLI/MCP/Dashboard/remote action was performed.` + +- [ ] **Step 7: Run the full fresh shared, Android, iOS, unit, lint, and package gates** + +Run separately so each platform boundary has its own exit evidence: + +~~~powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest --continue --rerun-tasks --console=plain +if ($LASTEXITCODE -ne 0) { throw 'Full shared Android-host suite failed' } +$fullSharedTotal = Assert-CleanJUnit 'Full shared Android-host suite' $null $true +if ($fullSharedTotal -lt 256) { throw "Full shared suite executed only $fullSharedTotal tests" } +$profileSkipped = @(Get-ChildItem 'shared/build/test-results/testAndroidHostTest' -Filter 'TEST-*.xml' | Where-Object { + $_.Name -match 'Profile|SettingsManager|SafeWord|VerbalEncouragement|Migration|KoinModule' +} | ForEach-Object { + [xml]$xml = Get-Content -Raw $_.FullName + if ([int]$xml.testsuite.skipped -ne 0) { $_.Name } +}) +if ($profileSkipped) { throw "Profile feature suites were skipped: $($profileSkipped -join ', ')" } + +.\gradlew.bat '-Pskip.supabase.check=true' :shared:compileAndroidMain --rerun-tasks --console=plain +if ($LASTEXITCODE -ne 0) { throw 'Android shared-main compilation failed' } + +.\gradlew.bat '-Pskip.supabase.check=true' :shared:compileKotlinIosArm64 :shared:compileTestKotlinIosArm64 --rerun-tasks --console=plain +if ($LASTEXITCODE -ne 0) { throw 'iOS main/test compilation failed' } + +.\gradlew.bat '-Pskip.supabase.check=true' :androidApp:testDebugUnitTest :androidApp:lintDebug :androidApp:assembleDebug --continue --rerun-tasks --console=plain +if ($LASTEXITCODE -ne 0) { throw 'Android unit/lint/assemble gate failed' } +$apk = Get-Item 'androidApp/build/outputs/apk/debug/androidApp-debug.apk' -ErrorAction Stop +if ($apk.Length -le 0) { throw 'Debug APK is empty' } +"Debug APK: $($apk.FullName) ($($apk.Length) bytes)" +~~~ + +Expected: the dynamically counted full shared suite is clean with no skipped profile-feature suite; Android common code compiles; iOS main and common tests compile; Android unit tests and `lintDebug` pass; and a non-empty debug APK is produced. + +- [ ] **Step 8: Record, but do not broaden into, the known repository-wide Spotless baseline** + +At authoring time, read-only `spotlessCheck` fails `:spotlessKotlinCheck` on 210 Kotlin files (three displayed plus 207 additional). This is a known repository-wide baseline, not permission to mutate 210 files and not evidence that formatting is green. + +~~~powershell +$beforeSpotlessHead = git rev-parse HEAD +$beforeSpotlessStatus = @(git status --porcelain=v1 --untracked-files=all) +$spotlessOutput = & .\gradlew.bat '-Pskip.supabase.check=true' spotlessCheck --rerun-tasks --console=plain 2>&1 +$spotlessExit = $LASTEXITCODE +$spotlessOutput | ForEach-Object { Write-Host $_ } +$spotlessViolationCount = @( + Get-ChildItem 'build/spotless-clean/spotlessKotlin' -Recurse -File -ErrorAction SilentlyContinue +).Count +if ($spotlessExit -eq 0) { + 'Spotless unexpectedly reached a clean repository baseline; record BUILD SUCCESSFUL.' +} elseif (($spotlessOutput -join [Environment]::NewLine) -notmatch 'spotlessKotlinCheck') { + throw 'Spotless failed for a reason other than the known Kotlin formatting baseline' +} else { + "Known Spotless baseline reproduced: $spotlessViolationCount Kotlin files" +} +if ((git rev-parse HEAD) -ne $beforeSpotlessHead) { throw 'Spotless changed HEAD' } +$afterSpotlessStatus = @(git status --porcelain=v1 --untracked-files=all) +if (Compare-Object $beforeSpotlessStatus $afterSpotlessStatus) { + throw 'spotlessCheck changed tracked/untracked source state' +} +~~~ + +Never run `spotlessApply`, a path-scoped apply, or a broad formatting commit in Task 11. Report Spotless as either newly green or as the exact reproduced baseline; do not call it passing when it failed. `git diff --check` and Android `lintDebug` remain hard final gates. If repository policy requires Spotless green, open a separate cleanup task with its own scope and review. + +- [ ] **Step 9: Perform realistic local/offline emulator acceptance** + +Use the Step 7 debug APK, a disposable Android emulator, and local data only. Record the emulator model, API level, pixel size, density, calculated width in dp, APK path, and pass/fail for every row below. Do not sign in or enable portal sync. Disable emulator networking before creating test profiles. + +The manual matrix is: + +1. Fresh install at 320dp width: exactly five icon-only root tabs render without clipping in Analytics → Insights → Home → Profile → Settings order. +2. Profile tap navigates and restores saved tab state. Profile long press produces haptic feedback, opens the sole root sheet, and does not navigate. With TalkBack enabled, Profile exposes separate labeled click and long-click actions. +3. Create local profiles A and B. Creation activates only after success. Give A and B distinct body weight/unit/increment, rack, workout timers/audio/auto-start/scaling, LED, VBT, verbal, and local safety values. A → B swaps all values; B → A restores A exactly. +4. Select different exercises for A and B. A → B → A restores each selection without flashing stale metrics. Verify current 1RM precedence, three PR highlights, five-session history, and the empty state with deterministic local workout data. +5. Edit the active profile. Default exposes no delete action. A non-default active profile can be deleted through the guarded flow and its data is reassigned according to the tested merge policy. +6. Equipment Rack management and Achievements open from Profile and are absent from Settings. Preserve Set Ready's existing workout-scoped rack selection/management access. The ready-list order is header, insights, achievements, preferences heading, preferences. Settings shows only its eight global groups in the required order. +7. Home edge gestures and Just Lift expose no profile selector. Disabling VBT suppresses live evaluation/feedback without hiding historical VBT/assessment insights. +8. Upgrade fixture: start from a disposable emulator snapshot whose app data was created by the reviewed schema-42 APK with populated legacy global preferences. Install the current APK with `adb install -r` without clearing data. Verify the awaited boot gate, one normalized copy into the active profile, product defaults for a newly created profile, and a Ready active context after reconciliation. If this reviewed fixture is unavailable, record the migration manual row as `NOT VERIFIED` and the final verdict is `NOT READY`; Task 11 may not create repository code or a remote service to fake it. + +Repository failure injection, `Switching(null)`, stale-token races, partial insight failures, sync privacy, and acknowledgement conflicts remain automated unless an already-reviewed deterministic local fixture exists. Do not claim those as manually exercised from an ordinary build. + +Expected: a recorded, reproducible local/offline acceptance result. Any failed or unverified required row blocks the final verdict and returns to its owning task; no remote account or Supabase state is touched. + +- [ ] **Step 10: Prove immutable scope and write the evidence-only handoff** + +~~~powershell +git diff --check 'af421bec..HEAD' +if ($LASTEXITCODE -ne 0) { throw 'Feature range has whitespace errors' } +git diff --check '6ddf9031..HEAD' +if ($LASTEXITCODE -ne 0) { throw 'Sync range has whitespace errors' } + +$featureRange = @(git diff --name-only 'af421bec..HEAD') +$forbiddenArtifacts = @($featureRange | Where-Object { + $_ -match '(^|/)(build|out)/' -or + $_ -match '[.](apk|aab|klib|class)$' -or + $_ -match '^supabase/' +}) +if ($forbiddenArtifacts) { + throw "Tracked generated/portal artifacts in feature range: $($forbiddenArtifacts -join ', ')" +} + +git diff --stat 'af421bec..HEAD' +git diff --stat '6ddf9031..HEAD' +if ((git branch --show-current) -ne $task11Branch) { throw 'Task 11 changed branches' } +if ((git rev-parse HEAD) -ne $task11StartHead) { throw 'Task 11 changed HEAD' } +git diff --cached --quiet +if ($LASTEXITCODE -ne 0) { throw 'Task 11 staged files' } +$finalDirty = @(git status --porcelain=v1 --untracked-files=all) +if ($finalDirty.Count -ne 0) { + $finalDirty | ForEach-Object { Write-Host $_ } + throw 'Task 11 left a dirty worktree' +} +~~~ + +The final handoff response, not a repository file, records: + +- branch and unchanged start/end HEAD; +- exact 32, 109, 158, 34, schema/migration, sync, Koin, and full-suite XML totals with failures/errors/skips; +- schema 43, tracked `42.sqm`, and manifest 389/46 evidence; +- Android, iOS main/test, Android unit, lint, APK, and emulator/API/320dp evidence; +- Spotless's actual green result or exact known-baseline failure count, never a false pass; +- privacy, ownership, Settings separation, local-only, legacy-removal, and no-remote audit outcomes; +- the exact statement `No Supabase CLI/MCP/Dashboard/remote action was performed.`; and +- `READY` only if every hard and manual gate passed, otherwise `NOT READY` with each failure routed to its owning earlier task. + +Expected: both reviewed ranges pass whitespace/scope inspection, no generated build or portal target is tracked, branch and HEAD are unchanged, the index/worktree are empty, and Task 11 creates no commit. + +--- diff --git a/docs/superpowers/plans/2026-07-12-profile-release-readiness-fixtures.md b/docs/superpowers/plans/2026-07-12-profile-release-readiness-fixtures.md new file mode 100644 index 000000000..b51d25729 --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-profile-release-readiness-fixtures.md @@ -0,0 +1,275 @@ +# Profile Release-Readiness Fixtures Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Produce a reviewed schema-42 upgrade fixture and a deterministic, local-only debug Profile dataset so every remaining Profile release-readiness check can be repeated without remote services. + +**Architecture:** A one-line fixture branch materializes the missing SQLDelight schema-42 APK from the pre-profile commit. The current branch gains an explicit debug-only broadcast seeder that writes through the real repositories, while a debug `PortalApiClient` subclass blocks profile fixture data from all portal push/pull traffic. APKs and AVD snapshots remain untracked; checksummed recipes, fixture input, automated release-boundary tests, and the final evidence report are tracked. + +**Tech Stack:** Kotlin/JVM and Android, Kotlin Multiplatform repositories, SQLDelight 2, Koin, coroutines, JUnit4, Android debug source sets, PowerShell, adb, Android Emulator API 36. + +## Global Constraints + +- No Supabase CLI, MCP, Dashboard, portal authentication, remote SQL, or remote network mutation is permitted. +- Disable emulator networking before injecting legacy values, seeding QA profiles, or launching the upgraded app. +- The schema-42 fixture branch starts at `ac84d9bb8e156002833ad526bf324a8f12710da0` and changes only SQLDelight's version/comment from 41 to 42; it must not contain `42.sqm`. +- Both fixture and current APKs use `com.devil.phoenixproject.debug`, version code 5, and the same local debug keystore. +- QA fixture behavior exists only under `androidApp/src/debug`; release artifacts contain no receiver, application override, gate, seeder, action, or QA profile literal. +- Setting the QA fixture flag must happen synchronously before any seed write. While set, `pushPortalPayload` and `pullPortalPayload` return a local failure before opening a network request. +- The QA fixture is explicit, never automatic at startup, idempotent, and uses exact profile names `[QA] Profile A` and `[QA] Profile B`. +- Seed writes use repository APIs except for fixture-owned cleanup where no delete API exists. Never reset through `UserProfileRepository.deleteProfile`, because deletion merges data into Default. +- Seed exactly five completed sessions per QA profile for one deterministic catalog exercise, two stored PR types that render all three insight highlights, one assessment, one passing velocity 1RM, and per-rep VBT metrics. +- Profile A and B have visibly distinct complete core, rack, workout, LED, VBT, and local-safety preferences. Profile B has VBT disabled while historical assessment and velocity data remain present. +- The exact five-item bottom-bar order is Analytics, Insights, Home, Profile, Settings. Home uses the existing `NavigationRoutes.Home` destination, a Home icon, localized Home semantics, and the stable `NAV_HOME` test tag; no Workouts bottom item remains. +- APKs, emulator images, AVD directories, snapshots, databases, logs, and hashes containing absolute machine paths are stored outside Git under `.phoenix-review/profile-readiness/`. +- Any new production-visible Kotlin behavior follows strict red-green-refactor TDD. Documentation/configuration artifacts have contract tests before their final contents are added. +- Task 11 remains verification-only. All fixture and harness work is completed and reviewed before rerunning it. + +--- + +### Task 1: Track and Materialize the Genuine Schema-42 Fixture + +**Files:** +- Create: `androidApp/src/test/kotlin/com/devil/phoenixproject/qa/Schema42FixtureContractTest.kt` +- Create: `docs/qa/fixtures/profile-schema42/vitruvian_preferences.xml` +- Create: `docs/qa/profile-schema42-fixture.md` +- Modify only in fixture worktree: `shared/build.gradle.kts` + +**Interfaces:** +- Consumes: pre-profile commit `ac84d9bb8e156002833ad526bf324a8f12710da0`, migrations through `41.sqm`, Android debug signing configuration. +- Produces: branch `codex/schema-42-fixture`, a schema-42 debug APK, a named AVD snapshot, fixture input checksum, APK checksum/signer evidence, and a reproducible upgrade recipe. + +- [ ] **Step 1: Write the failing fixture contract test** + +Create a JUnit test that resolves the repository root and asserts the tracked XML contains exact sentinel entries for `weight_unit=KG`, `body_weight_kg=82.5`, `weight_increment=2.5`, `color_scheme=3`, `summary_countdown_seconds=15`, `autostart_countdown_seconds=7`, `velocity_loss_threshold_percent=35`, and `default_scaling_basis=ESTIMATED_1RM`. It must assert `profile_preferences_legacy_migration_complete_v1` is absent, the rack JSON contains `fixture-vest`, and the fixture guide pins the full `ac84d9bb...` SHA plus the exact schema-version diff. + +- [ ] **Step 2: Run the contract and observe RED** + +Run: + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :androidApp:testDebugUnitTest --tests "com.devil.phoenixproject.qa.Schema42FixtureContractTest" --rerun-tasks --console=plain +``` + +Expected: failure because the tracked XML and guide do not exist. + +- [ ] **Step 3: Add the XML and reproduction guide** + +The XML is a complete Android `map` document containing valid values for every legacy profile-owned key enumerated by `ProfilePreferencesMigration`. Use the exact rack JSON: + +```json +[{"id":"fixture-vest","name":"Fixture Vest","category":"WEIGHTED_VEST","weightKg":10.0,"behavior":"ADDED_RESISTANCE","enabled":true,"sortOrder":7,"createdAt":1700000000000,"updatedAt":1700000001000}] +``` + +The guide includes creation, offline build, install, injection, snapshot, checksum, immutable restore, `adb install -r`, and post-upgrade SQL inspection commands. It explicitly forbids uninstall/`pm clear` between fixture and upgrade. + +- [ ] **Step 4: Run the contract GREEN and commit tracked fixture inputs** + +Run the Step 2 command again. Expected: one clean passing suite. Commit: + +```powershell +git add androidApp/src/test/kotlin/com/devil/phoenixproject/qa/Schema42FixtureContractTest.kt docs/qa/fixtures/profile-schema42/vitruvian_preferences.xml docs/qa/profile-schema42-fixture.md +git commit -m "test: define schema 42 upgrade fixture" +``` + +- [ ] **Step 5: Create the isolated schema-42 worktree and fixture commit** + +From the repository root: + +```powershell +git check-ignore -q .worktrees/profile-schema42 +git worktree add .worktrees/profile-schema42 -b codex/schema-42-fixture ac84d9bb8e156002833ad526bf324a8f12710da0 +``` + +In the fixture worktree, change only: + +```kotlin +// Version 42 = initial schema (1) + 41 migrations (1.sqm through 41.sqm). +version = 42 +``` + +Use `apply_patch`, then verify `42.sqm` is absent, run SQLDelight generation/migration checks and `:androidApp:assembleDebug` offline, and commit `test: materialize schema 42 fixture`. + +- [ ] **Step 6: Create the disposable API-36 AVD fixture and record immutable evidence** + +Use the installed API 36 Google Play x86_64 image. Install the schema-42 debug APK, launch once, force-stop, inject the tracked XML with `run-as`, save named snapshot `phoenix-schema42-v1`, and record fixture commit, APK SHA-256, signer digest, XML SHA-256, emulator/adb versions, AVD/API/ABI, and per-file snapshot hashes under `.phoenix-review/profile-readiness/schema42/`. + +--- + +### Task 2: Enforce the Debug-Only and Local-Only Boundary + +**Files:** +- Create: `androidApp/src/test/kotlin/com/devil/phoenixproject/qa/QaReleaseBoundaryTest.kt` +- Create: `androidApp/src/testDebug/kotlin/com/devil/phoenixproject/qa/QaBlockingPortalApiClientTest.kt` +- Create: `androidApp/src/debug/AndroidManifest.xml` +- Create: `androidApp/src/debug/kotlin/com/devil/phoenixproject/qa/ProfileQaDebugApp.kt` +- Create: `androidApp/src/debug/kotlin/com/devil/phoenixproject/qa/ProfileQaFixtureGate.kt` +- Create: `androidApp/src/debug/kotlin/com/devil/phoenixproject/qa/QaBlockingPortalApiClient.kt` +- Modify: `androidApp/src/main/kotlin/com/devil/phoenixproject/VitruvianApp.kt` + +**Interfaces:** +- Consumes: `PortalApiClient`, `PortalSyncPayload`, `KnownEntityIds`, `PortalTokenStorage`, `SupabaseConfig`, Koin startup. +- Produces: `ProfileQaFixtureGate`, `QaBlockingPortalApiClient`, and a debug application override installed before any Activity can resolve `SyncManager`. + +- [ ] **Step 1: Write failing boundary and blocker tests** + +The release-boundary contract scans `androidApp/src/main`, `src/release`, and the merged release manifest inputs and rejects `ProfileQa`, `QA_SEED_PROFILE`, `[QA] Profile`, or `QaBlockingPortalApiClient`. It requires all QA sources beneath `src/debug` except tests/docs. The blocker test uses a Ktor mock engine and proves push and pull make zero requests when the gate is set, returning `PortalApiException("QA fixture is local-only")`. + +- [ ] **Step 2: Run RED** + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :androidApp:testDebugUnitTest --tests "*QaReleaseBoundaryTest" --tests "*QaBlockingPortalApiClientTest" --rerun-tasks --console=plain +``` + +Expected: missing debug gate/client/application classes. + +- [ ] **Step 3: Implement the minimum debug gate and client** + +`ProfileQaFixtureGate` wraps a private debug SharedPreferences file, with `isEnabled()` and synchronous `enable()`. `QaBlockingPortalApiClient` subclasses `PortalApiClient`; its push/pull overrides return the exact local failure when enabled and otherwise delegate to `super`. `ProfileQaDebugApp` subclasses an `open VitruvianApp`, calls `super.onCreate()`, then loads a Koin override for `PortalApiClient` before Activity creation. The debug manifest replaces the application name with `.qa.ProfileQaDebugApp`. + +- [ ] **Step 4: Run GREEN and verify release assembly** + +Run the Step 2 command plus: + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :androidApp:assembleDebug :androidApp:assembleRelease '-Pversion.code=999999' --rerun-tasks --console=plain +``` + +Inspect the release merged manifest and release APK dex/string inventory; no QA symbols/actions may occur. Commit `test: isolate profile QA fixtures to debug`. + +--- + +### Task 3: Seed Deterministic Profiles, Insights, Racks, and VBT History + +**Files:** +- Create: `androidApp/src/testDebug/kotlin/com/devil/phoenixproject/qa/ProfileQaSeederTest.kt` +- Create: `androidApp/src/testDebug/kotlin/com/devil/phoenixproject/qa/ProfileQaSeedReceiverTest.kt` +- Create: `androidApp/src/debug/kotlin/com/devil/phoenixproject/qa/ProfileQaSeeder.kt` +- Create: `androidApp/src/debug/kotlin/com/devil/phoenixproject/qa/ProfileQaSeedReceiver.kt` +- Modify: `androidApp/src/debug/AndroidManifest.xml` + +**Interfaces:** +- Consumes: `UserProfileRepository`, `ExerciseRepository`, `WorkoutRepository`, `RepMetricRepository`, `PersonalRecordRepository`, `AssessmentRepository`, `VelocityOneRepMaxRepository`, and `VitruvianDatabase` only for fixture-row cleanup unavailable through repository interfaces. +- Produces: `ProfileQaSeeder.seed(): ProfileQaSeedResult` and exported debug broadcast action `com.devil.phoenixproject.QA_SEED_PROFILE`. + +- [ ] **Step 1: Write failing seeder and receiver tests** + +Tests require: exact/reused A/B profile identities; complete inverse typed preferences; deterministic rack IDs; exactly five fixed sessions/profile after two seed runs; per-session rep metrics; max-weight/max-volume records yielding three visible highlight values; one assessment; a newer passing velocity result whose total 1RM is higher than assessment/session fallback; B's VBT disabled with history retained; fixture gate enabled before the first write; `goAsync()` completion on success/failure; and one stable log/result marker without preference values. + +- [ ] **Step 2: Run RED** + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :androidApp:testDebugUnitTest --tests "*ProfileQaSeederTest" --tests "*ProfileQaSeedReceiverTest" --rerun-tasks --console=plain +``` + +Expected: missing seeder/receiver APIs. + +- [ ] **Step 3: Implement minimal idempotent fixture data** + +Use catalog exercise `Bench Press`. Profiles use exact names and deterministic visible values. Seed fixed sessions at timestamps `1700100000000` through `1700500000000`, with increasing completed loads and nonzero velocity/force/volume metrics. Save 50 kg/cable × 3 reps for max weight and 40 kg/cable × 12 reps for max volume. Save an assessment below the velocity result. Insert a passing velocity estimate of 77 kg/cable, MVT 0.30 m/s, R² 0.97, three loads, at a fixed newer marker. Remove only prior fixture-owned rows before reinsertion; never delete a QA profile through profile deletion. + +The receiver enables the gate synchronously, calls `goAsync()`, launches a supervised IO coroutine, invokes `seed()`, logs only `PROFILE_QA_SEED_OK` or `PROFILE_QA_SEED_FAILED:`, and always finishes the pending result. + +- [ ] **Step 4: Run GREEN, repository regressions, and commit** + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :androidApp:testDebugUnitTest --tests "*ProfileQa*" --rerun-tasks --console=plain +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest --tests "*SqlDelightWorkoutRepositoryTest*" --tests "*SqlDelightAssessmentRepositoryTest*" --tests "*SqlDelightVelocityOneRepMaxRepositoryTest*" --rerun-tasks --console=plain +``` + +Expected: zero failures/errors/skips. Commit `test: add deterministic profile QA seed`. + +--- + +### Task 3A: Put Home in the Center Bottom-Bar Position + +**Files:** +- Modify: `shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/navigation/ProfileNavigationContractTest.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/navigation/NavigationRoutes.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/EnhancedMainScreen.kt` +- Modify: `shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/util/TestTags.kt` +- Modify: all five `shared/src/commonMain/composeResources/values*/strings.xml` files + +**Interfaces:** +- Consumes: the existing `NavigationRoutes.Home` and `NavigationRoutes.SmartInsights` destinations and five equal-width root cells. +- Produces: exact `ANALYTICS, INSIGHTS, HOME, PROFILE, SETTINGS` enum/render order, localized Home semantics, `Icons.Default.Home`, and `TestTags.NAV_HOME`. + +- [ ] **Step 1: Change the navigation contract first** + +Require exact enum order `ANALYTICS, INSIGHTS, HOME, PROFILE, SETTINGS`; `HOME(NavigationRoutes.Home.route)`; no `BottomNavItem.WORKOUT`; Home icon/semantic/click/selection branches; `NAV_HOME` present once and `NAV_WORKOUTS` absent; Profile remains fourth with click and long-click behavior unchanged. + +- [ ] **Step 2: Run RED** + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest --tests "com.devil.phoenixproject.presentation.navigation.ProfileNavigationContractTest" --rerun-tasks --console=plain +``` + +Expected: the order/tag assertions fail against the old Analytics/Workout/Insights order. + +- [ ] **Step 3: Implement the exact order and Home semantics** + +Rename the root enum/callback/selection/content-description/test-tag symbols from Workout to Home, move Insights to the second enum entry, and use localized `cd_home` values: English `Home`, German `Startseite`, Spanish `Inicio`, Dutch `Startpagina`, French `Accueil`. Retain the underlying Home route and all workout-flow routes. + +- [ ] **Step 4: Run GREEN and regressions** + +Run the Step 2 command plus the exact Task 10 34-test ownership suite and Android/iOS main/test compilation. Expected: zero failures/errors/skips. Commit `fix: center Home in bottom navigation`. + +--- + +### Task 4: Execute Offline Upgrade and Populated Profile Acceptance + +**Files:** +- Create after evidence exists: `docs/qa/profile-release-readiness-2026-07-12.md` +- Modify: none elsewhere. + +**Interfaces:** +- Consumes: immutable schema-42 snapshot, current debug APK, QA seed action, Task 11 manual matrix. +- Produces: checksummed local evidence and a row-by-row acceptance result without remote actions. + +- [ ] **Step 1: Validate the schema-42 to 43 upgrade** + +Restore `phoenix-schema42-v1` with `-force-snapshot-load -no-snapshot-save`, keep networking disabled, run `adb install -r` with the current APK, launch, await the migration gate, and pull the debuggable DB/preferences. Verify user version 43, exactly one normalized copy into every existing profile with `legacy_migration_version=1`, the completion flag, VBT forced enabled for migrated profiles, a Ready context, and unchanged normalized values after a second launch. + +- [ ] **Step 2: Seed and verify the complete Profile matrix** + +Install/launch the current debug APK on a fresh disposable AVD, disable networking, broadcast the seed action, and wait for `PROFILE_QA_SEED_OK`. First verify the exact Analytics → Insights → Home → Profile → Settings icon-only bottom order and localized Home semantics. Through UI-tree-derived coordinates verify A/B preference swaps including racks and local safety, five-session history, all three PR highlights, velocity 1RM precedence, selection isolation, B's disabled VBT with historical assessment/velocity insight still visible, rename/delete guard, global-only Settings, Profile management entries for Equipment Rack/Achievements, preserved workout-scoped Set Ready rack access, and absence of legacy selectors. + +- [ ] **Step 3: Exercise accessibility and haptic evidence** + +Enable installed TalkBack, focus the Profile item, capture the exposed click and long-click actions using TalkBack's action menu/accessibility-node evidence, and trigger long press. Record vibrator-service evidence for the app UID. If a physical Android device is connected, additionally confirm audible labels and tactile feedback there; do not substitute a claim of physical sensation when no device exists. + +- [ ] **Step 4: Exercise live VBT evidence** + +If a compatible trainer is connected, perform identical telemetry with VBT on/off and confirm feedback appears only when enabled while history remains. Without trainer hardware, run the reviewed runtime simulation suites and record hardware validation as unavailable rather than fabricating it. + +- [ ] **Step 5: Write and test the evidence report** + +The report records branch/HEADs, fixture hashes, emulator/API/density/width, commands, exact test counts, every manual row, unavailable external hardware evidence, Spotless's actual result, worktree cleanliness, and the exact sentence: `No Supabase CLI/MCP/Dashboard/remote action was performed.` Add a contract test if any required evidence field is machine-checkable, then commit `docs: record profile readiness fixture evidence`. + +--- + +### Task 5: Final Independent Review and Verification + +**Files:** +- Verify only: every Task 1-4 file and generated artifact boundary. +- Modify: none unless a reviewer reports a Critical or Important defect. + +**Interfaces:** +- Consumes: all reviewed task commits and evidence. +- Produces: final branch verdict and a reusable schema/history fixture handoff. + +- [ ] **Step 1: Run focused and full gates** + +Run all new QA contracts, the exact existing 32/109/158/34 Profile suites, schema/migration, sync/privacy, repository-race, Koin, full Android-host suite, Android/iOS compilation, Android unit/lint/debug/release assembly, and `git diff --check`. Record exact XML totals. + +- [ ] **Step 2: Reproduce Spotless without mutation** + +Run `spotlessCheck` only, record the exact known-baseline count or new green result, and verify HEAD/status are unchanged. Never run `spotlessApply` in this task. + +- [ ] **Step 3: Perform independent whole-branch review** + +Review for debug/release isolation, remote fail-closed behavior, fixture idempotence, repository invariants, sensitive-data logging, schema authenticity, and evidence accuracy. Fix Critical/Important findings with covering tests and repeat review. + +- [ ] **Step 4: Finish the branch** + +Use `superpowers:finishing-a-development-branch`, present the verified integration options, and retain external fixture artifacts until the user chooses cleanup. diff --git a/docs/superpowers/specs/2026-07-11-profile-tab-preferences-design.md b/docs/superpowers/specs/2026-07-11-profile-tab-preferences-design.md new file mode 100644 index 000000000..5d294c09a --- /dev/null +++ b/docs/superpowers/specs/2026-07-11-profile-tab-preferences-design.md @@ -0,0 +1,674 @@ +# Profile Tab, Contextual Preferences, and Settings Migration — Design + +**Date:** 2026-07-11 + +**Profile preferences correction:** 2026-07-15 (PR #651) + +**Status:** Approved (design), pending written-spec review +**Scope:** `Project-Phoenix-MP` mobile implementation plus a Supabase/Edge Function backend handoff. The `phoenix-portal` source is not present in this workspace, so applying backend changes is out of scope for this repository. + +## Summary + +Add a dedicated Profile root tab to the shared Compose bottom navigation. A normal tap opens a new `ProfileScreen`; a long press opens a profile-switcher sheet. Remove the Home screen's edge-swipe `ProfileSidePanel` and obsolete `ProfileSpeedDial` UI. + +Move person-specific training preferences out of the global multiplatform Settings store and into a profile-scoped SQLDelight aggregate. Existing profiles receive a snapshot of today's global values during an idempotent startup migration; profiles created afterward receive clean product defaults. App-, device-, account-, and maintenance-level settings remain global. + +The Profile screen combines a compact, exercise-specific insights dashboard with profile management and preference controls. Sync mirrors safe preference sections to a new Supabase `local_profile_preferences` relation associated with the existing `local_profiles` model. Sensitive safety and consent values remain profile-scoped but device-local. + +## Goals + +- Make Profile a first-class root destination. +- Make switching profiles switch all person-specific workout behavior without restarting the app. +- Establish SQLDelight as the source of truth for syncable profile preferences. +- Preserve existing behavior for every profile present at upgrade time. +- Keep sensitive safety and consent values off the network and out of backup exports. +- Reuse the existing exercise picker and exercise-detail analytics logic. +- Leave Settings with only global app/device/account/maintenance controls. +- Provide an executable Supabase SQL handoff and an exact Edge Function contract change list. + +## Non-goals + +- Applying or deploying Supabase migrations or Edge Functions from this repository. +- Redesigning the full `ExerciseDetailScreen` analytics experience. +- Changing workout-history, routine, PR, or profile-deletion ownership rules. +- Introducing client-side encryption or cross-device key management for the voice-stop phrase. +- Deleting legacy Settings keys in the same release as the migration. +- Reworking portal authentication or mapping one local profile to one Supabase auth user. +- Adding cross-device create/rename/delete synchronization for local-profile metadata. Preference pull applies only to profile IDs already present on the device; it does not invent a profile tombstone protocol. + +## Approved product decisions + +- Profiles own all person-specific training behavior, not merely the six originally listed fields. +- The remote parent is the existing `local_profiles` model, not account-level `user_profiles`. +- The voice-stop phrase, calibration state, and 18+ consent/prompt state are profile-scoped and device-local. +- All profiles existing at migration time receive the legacy global snapshot. New profiles start from defaults. +- `vbtEnabled` controls live workout VBT enforcement and feedback. It does not hide historical VBT data or disable assessment workflows. +- Rename and delete actions live on `ProfileScreen`; the switcher sheet switches and creates only. +- Exercise Insights is a compact dashboard with a link to the existing full exercise history. +- Backend work is delivered as a handoff artifact rather than modified in a second repository. +- The selected storage architecture is a dedicated 1:1 preference aggregate with per-section local timestamps, dirty flags, and server revisions. + +## Existing-system constraints + +- The current SQLDelight schema version is 42; `41.sqm` is the latest existing migration. The new migration is `42.sqm`, upgrading schema 42 to 43. +- `shared/build.gradle.kts` is stale at `version = 41` despite the presence of `41.sqm` and parity tests expecting logical schema 42. Implementation must reconcile the Gradle version, generated schema, parity constant, fallback map, and migration numbering together, ending at version 43 with `42.sqm` included. +- A `.sqm` migration can access only SQLite. Legacy preferences live in Android SharedPreferences and iOS NSUserDefaults through multiplatform Settings, so copying legacy values must happen in application code after the schema upgrade. +- `MigrationManager` already has access to the database, profile repository, and Settings store, providing the correct home for the idempotent cross-store migration. +- The current remote DTO is `LocalProfileDto(id, name, colorIndex)`, and pull already models `localProfiles`, but production pull does not apply that metadata locally. +- Equipment Rack is currently a JSON list of `RackItem` values, not a selectable rack entity. The migrated value is therefore the complete profile-owned rack catalog, not `equipment_rack_id`. +- Backup is a manually mapped, versioned JSON contract rather than a raw database copy. Version 4 exports one global `equipmentRackItems` list, so the new profile aggregate must be added explicitly to both buffered and streaming backup paths. +- `ExerciseDetailScreen` already implements most requested analytics: a velocity estimate, formula estimate, progression, volume, and recent history. Shared logic should be extracted rather than duplicated. +- `SettingsTab` is currently a large monolith. Preference controls must be extracted into focused reusable components before composing the new Profile screen. + +## Preference ownership + +### Profile-scoped and syncable + +#### Core measurements + +- `weightUnit` +- `weightIncrement` +- `bodyWeightKg` + +#### Equipment Rack + +- Complete `List` catalog, including enabled state, ordering, behavior, and weight + +#### Workout behavior + +- `stopAtTop` +- `beepsEnabled` +- `stallDetectionEnabled` +- `audioRepCountEnabled` +- `repCountTiming` +- `summaryCountdownSeconds` +- `autoStartCountdownSeconds` +- `gamificationEnabled` +- `autoStartRoutine` +- `countdownBeepsEnabled` +- `repSoundEnabled` +- `motionStartEnabled` +- `weightSuggestionsEnabled` +- `defaultRoutineExerciseUsePercentOfPR` +- `defaultRoutineExerciseWeightPercentOfPR` +- Just Lift defaults +- Per-exercise quick-start defaults +- `voiceStopEnabled` as user intent; effective voice-stop operation still requires a locally configured and calibrated phrase + +#### LED behavior + +- `colorScheme` +- `discoModeUnlocked` + +`discoModeActive` remains transient BLE runtime state. It is neither persisted nor synchronized. + +#### VBT behavior + +- `vbtEnabled` +- `velocityLossThresholdPercent` +- `autoEndOnVelocityLoss` +- `defaultScalingBasis` +- `verbalEncouragementEnabled` +- `vulgarModeEnabled` +- `vulgarTier` +- `dominatrixModeUnlocked` +- `dominatrixModeActive` + +### Profile-scoped and device-local + +- `safeWord` +- `safeWordCalibrated` +- `adultsOnlyConfirmed` +- `adultsOnlyPrompted` + +These values use profile-prefixed multiplatform Settings keys. They are excluded from portal DTOs and backup exports. If a synced `voiceStopEnabled` value arrives on a device without a local calibrated phrase, the UI shows that setup is required and the workout engine treats voice stop as disabled. Synced vulgar/Dominatrix preferences likewise remain ineffective until local adult consent is present. + +### Global app/device/account preferences + +- Theme mode and dynamic color +- Language +- Video loading/playback behavior +- Auto-backup and backup destination +- Manual backup/restore and destructive data-management actions +- Health and external integration configuration +- Portal authentication and sync status +- BLE compatibility mode +- Connection logs, diagnostics, and developer tools +- App-version and donation information +- Internal run-once flags such as `velocityOneRepMaxBackfillDone` + +## Local data model + +Create `UserProfilePreferences` as a 1:1 relation to `UserProfile`: + +```sql +CREATE TABLE UserProfilePreferences ( + profile_id TEXT PRIMARY KEY NOT NULL, + schema_version INTEGER NOT NULL DEFAULT 1, + legacy_migration_version INTEGER NOT NULL DEFAULT 0, + + body_weight_kg REAL NOT NULL DEFAULT 0, + weight_unit TEXT NOT NULL DEFAULT 'LB', + weight_increment REAL NOT NULL DEFAULT -1, + core_updated_at INTEGER NOT NULL DEFAULT 0, + core_local_generation INTEGER NOT NULL DEFAULT 0, + core_server_revision INTEGER NOT NULL DEFAULT 0, + core_dirty INTEGER NOT NULL DEFAULT 1, + + equipment_rack_json TEXT NOT NULL DEFAULT '{"version":1,"items":[]}', + rack_updated_at INTEGER NOT NULL DEFAULT 0, + rack_local_generation INTEGER NOT NULL DEFAULT 0, + rack_server_revision INTEGER NOT NULL DEFAULT 0, + rack_dirty INTEGER NOT NULL DEFAULT 1, + + workout_preferences_json TEXT NOT NULL DEFAULT '{"version":1}', + workout_updated_at INTEGER NOT NULL DEFAULT 0, + workout_local_generation INTEGER NOT NULL DEFAULT 0, + workout_server_revision INTEGER NOT NULL DEFAULT 0, + workout_dirty INTEGER NOT NULL DEFAULT 1, + + led_color_scheme_id INTEGER NOT NULL DEFAULT 0, + led_preferences_json TEXT NOT NULL DEFAULT '{"version":1}', + led_updated_at INTEGER NOT NULL DEFAULT 0, + led_local_generation INTEGER NOT NULL DEFAULT 0, + led_server_revision INTEGER NOT NULL DEFAULT 0, + led_dirty INTEGER NOT NULL DEFAULT 1, + + vbt_enabled INTEGER NOT NULL DEFAULT 1, + vbt_preferences_json TEXT NOT NULL DEFAULT '{"version":1}', + vbt_updated_at INTEGER NOT NULL DEFAULT 0, + vbt_local_generation INTEGER NOT NULL DEFAULT 0, + vbt_server_revision INTEGER NOT NULL DEFAULT 0, + vbt_dirty INTEGER NOT NULL DEFAULT 1, + + FOREIGN KEY (profile_id) REFERENCES UserProfile(id) ON DELETE CASCADE +); + +CREATE TABLE PendingProfileLocalCleanup ( + profile_id TEXT PRIMARY KEY NOT NULL, + enqueued_at INTEGER NOT NULL +); +``` + +Each `*_updated_at` value is local audit/UI metadata, not a cross-device ordering authority. Each `*_local_generation` is a device-local monotonic counter incremented with every section edit and used to detect an edit racing an in-flight sync request; it is never serialized. Each `*_server_revision` is the last canonical revision acknowledged by Supabase. A local edit increments the generation and sets that section's dirty flag without changing its server revision. A canonical acknowledgement updates the revision and clears the dirty flag only when the current generation still matches the generation captured for the sent snapshot. + +The canonical schema will add validation where SQLite can enforce it safely: + +- body weight is `0` (unset) or 20–300 kg; +- weight unit is `KG` or `LB`; +- weight increment is `-1` (unit default) or positive; +- LED scheme IDs are non-negative; +- Boolean values are 0 or 1. + +The JSON columns are versioned Kotlin documents decoded with `ignoreUnknownKeys = true` and explicit defaults. UI and domain code never manipulate raw JSON. + +The schema change must be represented in all project migration paths: + +- `VitruvianDatabase.sq` canonical table and queries; +- `migrations/42.sqm`, including default rows for existing profiles; +- fallback migration statements for source schema 42; +- `SchemaManifest` table/column reconciliation entries; +- schema version/parity tests updated to version 43. + +Profile deletion explicitly removes preference rows as a fallback even when SQLite foreign-key cascading is unavailable on a platform connection. + +### Serialized document contract + +All local JSON and wire JSON uses the camelCase names below. Enum values use their current uppercase Kotlin names. The row-level `schema_version` versions the aggregate storage contract; every JSON document also has its own integer `version` so one section can evolve independently. + +`RackPreferencesDocument` defaults to `{"version":1,"items":[]}`. Each item uses the existing `RackItem` fields: `id: String`, `name: String`, `category: String`, `weightKg: Float`, `behavior: String`, `enabled: Boolean`, `sortOrder: Int`, `createdAt: Long`, and `updatedAt: Long`. + +`WorkoutPreferencesDocument` version 1 has these fields and defaults: + +- `stopAtTop: Boolean = false` +- `beepsEnabled: Boolean = true` +- `stallDetectionEnabled: Boolean = true` +- `audioRepCountEnabled: Boolean = false` +- `repCountTiming: String = "TOP"` +- `summaryCountdownSeconds: Int = 10` +- `autoStartCountdownSeconds: Int = 5` +- `gamificationEnabled: Boolean = true` +- `autoStartRoutine: Boolean = false` +- `countdownBeepsEnabled: Boolean = true` +- `repSoundEnabled: Boolean = true` +- `motionStartEnabled: Boolean = false` +- `weightSuggestionsEnabled: Boolean = true` +- `defaultRoutineExerciseUsePercentOfPR: Boolean = false` +- `defaultRoutineExerciseWeightPercentOfPR: Int = 80` +- `voiceStopEnabled: Boolean = false` +- `justLiftDefaults: JustLiftDefaultsDocument` +- `singleExerciseDefaults: Map = emptyMap()` + +`JustLiftDefaultsDocument` preserves the existing serialized fields and defaults: `workoutModeId = 0`, `weightPerCableKg = 20`, `weightChangePerRep = 0`, `eccentricLoadPercentage = 100`, `echoLevelValue = 1`, `stallDetectionEnabled = true`, `repCountTimingName = "TOP"`, and `restSeconds = 60`. + +`SingleExerciseDefaultsDocument` preserves the existing serialized fields: `exerciseId`, `setReps`, `weightPerCableKg`, `setWeightsPerCableKg`, `progressionKg`, `setRestSeconds`, `workoutModeId`, `eccentricLoadPercentage`, `echoLevelValue`, `duration`, `isAMRAP`, `perSetRestTime`, and `defaultRackItemIds`. + +`LedPreferencesDocument` version 1 contains only `discoModeUnlocked: Boolean = false`; the selected scheme remains the typed `led_color_scheme_id` column. + +`VbtPreferencesDocument` version 1 has these fields and defaults: + +- `velocityLossThresholdPercent: Int = 20` +- `autoEndOnVelocityLoss: Boolean = false` +- `defaultScalingBasis: String = "MAX_WEIGHT_PR"` +- `verbalEncouragementEnabled: Boolean = false` +- `vulgarModeEnabled: Boolean = false` +- `vulgarTier: String = "STRONG"` +- `dominatrixModeUnlocked: Boolean = false` +- `dominatrixModeActive: Boolean = false` + +The typed `vbt_enabled` column defaults to true. It is an independent master value and is never inferred from subordinate VBT fields after migration. + +Decoders ignore unknown fields and apply the defaults above for missing fields. Encoders always emit the document version. The backend handoff uses the same object shapes in JSONB rather than embedding JSON strings inside JSON. + +Validation preserves current product ranges: summary countdown is `-1`, `0`, or 5–30 seconds; auto-start countdown is 2–10 seconds; routine default percentage is 50–120; velocity-loss threshold is 10–50; rest time is 0/off or 5–300 seconds; weights must be finite and non-negative, with body weight using the separate 0/unset or 20–300 kg rule. Mobile validates before write, and the Edge Function repeats validation before accepting a mutation. + +Repository decode results carry section validity alongside the typed value. Unknown fields and missing optional fields remain valid; malformed JSON, unsupported document versions, and invalid field values mark only that section invalid. UI may render safe typed defaults, but sync never serializes those fallback defaults. The section remains locally visible, diagnostically unsynchronized, and dirty until the user explicitly edits or resets it to a valid document. + +## Kotlin models and repository boundary + +Introduce typed models: + +- `UserProfilePreferences` +- `CoreProfilePreferences` +- `WorkoutPreferences` +- `LedPreferences` +- `VbtPreferences` +- `ProfileLocalSafetyPreferences` +- `ActiveProfileContext` + +`UserProfileRepository` remains the public profile façade and delegates persistence to focused internal stores. It exposes: + +- active and all-profile identity streams; +- `ActiveProfileContext`, with `Switching` and `Ready(profile, preferences, localSafety)` states; +- observation by profile ID; +- typed update operations for core, rack, workout, LED, VBT, and local-safety sections; +- transactional SQL create-with-defaults and profile-data reassignment operations; +- idempotent, journaled cleanup of profile-prefixed Settings keys after SQL deletion commits. + +Each section update modifies only that section, local timestamp, local generation, and dirty flag. `ActiveProfileContext` emits `Switching` before changing the active row and emits `Ready` only after the new profile's preferences and local safety values are loaded. Workout-start actions are disabled while context is `Switching`, preventing mixed-profile configuration. + +`SettingsManager` remains a compatibility façade during this release: + +- global fields continue reading/writing `PreferencesManager`; +- profile fields read/write the active `UserProfileRepository` context; +- a compatibility `UserPreferences` stream may combine both sources for existing consumers; +- `ProfileScreen` uses `UserProfileRepository` directly; +- no profile-specific setter writes to the legacy global store. + +The equipment-rack repository becomes active-profile-aware and delegates its catalog to the rack section. Just Lift and per-exercise defaults move from unscoped Settings keys into the workout document. Health-imported body weight updates the active profile's core section. + +SQL and multiplatform Settings cannot share a transaction. Profile deletion therefore inserts `PendingProfileLocalCleanup(profile_id)` in the same SQL transaction that removes/reassigns profile data. After commit, the repository removes the profile-prefixed Settings keys and deletes the cleanup row. Startup retries every queued cleanup. Once the SQL profile is gone, queued sensitive values are unreachable even if physical key deletion must be retried. + +## Legacy migration + +Migration has two coordinated stages. + +### Stage 1: SQLDelight 42 → 43 + +`42.sqm` creates `UserProfilePreferences` and `PendingProfileLocalCleanup`, then inserts a default preference row for every `UserProfile` already in SQLite. This stage cannot read legacy Settings values. + +### Stage 2: idempotent startup copy + +After profiles are ensured, application code runs the following required migration behind a boot gate: + +1. Upserts a default `UserProfilePreferences` row for every `UserProfile`. This heals a current-version database where schema reconciliation created the table after the numbered migration had already been recorded. +2. Reads one snapshot from the legacy Settings store, including rack and quick-start JSON. +3. Normalizes the snapshot section by section before persistence: non-finite or out-of-range numbers use that field's documented default; unknown enums use the current default; malformed JSON uses the section default; rack arrays retain only valid, uniquely identified items; and invalid nested entries are dropped without discarding valid siblings. Every normalization is logged with the profile-independent legacy key and reason, never the sensitive value. +4. Writes the normalized snapshot to every existing preference row whose `legacy_migration_version` is 0. +5. Uses one migration timestamp for every copied section, sets every copied section dirty, and leaves every server revision at 0. +6. Sets `vbtEnabled = true` for every migrated profile because live velocity-loss analysis/indicator behavior is enabled unconditionally in the pre-migration app. New profiles also default to true. Only an explicit user edit or canonical synced value can turn the master off; subordinate toggles never derive or silently change it. +7. Copies current safe-word/calibration and consent values into profile-prefixed local keys for every existing profile. +8. Marks each completed row with migration version 1. +9. Writes a global completion flag only after all existing rows and profile-local keys succeed. + +On retry, rows already marked version 1 are not overwritten. New profiles are inserted with product defaults, dirty sections, server revision 0, and migration version 1. The active profile does not change during migration. + +The existing fire-and-forget `MigrationManager.checkAndRunMigrations()` call is insufficient for this required copy. Add a required-migration state (`NotStarted`, `Applying`, `Ready`, `Failed`) and a suspending/retryable entry point. Splash/root navigation observes that state and does not construct the main navigation graph or enable workout start until `Ready`. `Failed` shows an error and Retry action; it does not continue with partially migrated preferences. Malformed legacy values do not cause `Failed` because they normalize as described above; `Failed` is reserved for storage/transaction failures that can succeed on retry. Existing non-critical repair passes may remain asynchronous after this required gate. + +The same gate protects non-UI consumers. `SyncManager` may continue syncing unrelated workout entities while migration is not `Ready`, but it must neither read nor send profile-preference sections. Health imports and every background reader/writer of the new profile aggregate suspend or queue work until `Ready`; no consumer can observe or upload the temporary SQL defaults created by Stage 1. + +Route the current Android `VitruvianApp` and common `KoinInit` migration triggers through one idempotent coordinator so initialization cannot run twice. Android and iOS/shared entry paths must both reach the same required state before rendering the main app. + +Legacy keys remain in place for one release as downgrade protection, but all writes switch immediately to the new stores. + +`vbtEnabled` gates live velocity-loss evaluation/auto-end, live VBT threshold/zone feedback, and verbal/vulgar/Dominatrix VBT-failure feedback. It does not gate assessments, historical velocity estimates, PR/history display, or the stored scaling-basis preference. Turning the master off preserves subordinate configuration except that an active Dominatrix mode is forced off; the permanent unlock flag remains set. + +## Sync model and conflict resolution + +Add an internal `ProfilePreferenceSectionSyncDto` in `SyncModels.kt`. Wire format uses one section mutation at a time so large rack or per-exercise-default documents can be chunked independently: + +- `PortalProfilePreferenceSectionMutationDto(localProfileId, section, documentVersion, baseRevision, clientModifiedAt, payload)` for push; +- `PortalProfilePreferenceSectionCanonicalDto(localProfileId, section, documentVersion, serverRevision, serverUpdatedAt, payload)` for acknowledgements and pull; +- `ProfilePreferenceSectionRejectionDto(localProfileId, section, serverRevision, reason, canonicalSection = null)` for conflicts or validation failures. + +IDs, section, and reason are strings; document versions are `Int`; revisions are `Long`; client/server timestamps are ISO-8601 strings; `canonicalSection` is nullable; and `payload` is a JSON object. `section` is one of `CORE`, `RACK`, `WORKOUT`, `LED`, or `VBT`. + +The literal payload wrappers are: + +```text +CORE: {"bodyWeightKg":0.0,"weightUnit":"LB","weightIncrement":-1.0} +RACK: {"version":1,"items":[]} +WORKOUT: {"version":1,"stopAtTop":false,"beepsEnabled":true,"stallDetectionEnabled":true,"audioRepCountEnabled":false,"repCountTiming":"TOP","summaryCountdownSeconds":10,"autoStartCountdownSeconds":5,"gamificationEnabled":true,"autoStartRoutine":false,"countdownBeepsEnabled":true,"repSoundEnabled":true,"motionStartEnabled":false,"weightSuggestionsEnabled":true,"defaultRoutineExerciseUsePercentOfPR":false,"defaultRoutineExerciseWeightPercentOfPR":80,"voiceStopEnabled":false,"justLiftDefaults":{"workoutModeId":0,"weightPerCableKg":20.0,"weightChangePerRep":0.0,"eccentricLoadPercentage":100,"echoLevelValue":1,"stallDetectionEnabled":true,"repCountTimingName":"TOP","restSeconds":60},"singleExerciseDefaults":{}} +LED: {"ledColorSchemeId":0,"preferences":{"version":1,"discoModeUnlocked":false}} +VBT: {"vbtEnabled":true,"preferences":{"version":1,"velocityLossThresholdPercent":20,"autoEndOnVelocityLoss":false,"defaultScalingBasis":"MAX_WEIGHT_PR","verbalEncouragementEnabled":false,"vulgarModeEnabled":false,"vulgarTier":"STRONG","dominatrixModeUnlocked":false,"dominatrixModeActive":false}} +``` + +The labels before each object above name examples and are not part of the JSON payload. `documentVersion` is 1 for `CORE`. For `RACK` and `WORKOUT`, it must equal the payload's `version`; for `LED` and `VBT`, it must equal `preferences.version`. A mismatch is a section validation failure. Rack items and per-exercise-default values use the exact field names already listed in the serialized document contract. + +The additive wire fields are explicit: + +- `PortalSyncPushRequest.profilePreferenceSections: List? = null`; +- `PortalSyncPushResponse.profilePreferencesAccepted: Boolean? = null`, where `null` identifies a legacy backend; +- `PortalSyncPushResponse.canonicalProfilePreferenceSections: List = emptyList()`; +- `PortalSyncPushResponse.profilePreferenceRejections: List = emptyList()`; +- `PortalSyncPullResponse.profilePreferenceSections: List? = null`. + +These constructor defaults are required so kotlinx.serialization can decode responses from an older backend that omits every additive field. + +`SyncManager` first sends the existing `allProfiles` metadata snapshot so remote parent rows exist, even when no workout data is pending. Once required migration is `Ready`, it sends valid dirty preference sections through the same push endpoint in preference-only chunks capped at 512 KiB encoded size. A single encoded section is capped at 256 KiB. Invalid locally decoded sections are excluded rather than encoded from fallback defaults. Normal writes are validated before persistence; an oversized legacy section remains fully usable locally, is not truncated, and is reported as permanently unsynchronized while other sections continue. `profilePreferencesAccepted == true` means the backend recognized and evaluated the feature payload; canonical/rejection lists still determine each section's result. + +Pull applies entities in this order: + +1. Match preference rows to profile IDs already present locally. Log and ignore unknown profile IDs; never create or resurrect a profile from a preference row. +2. Merge matched profile preferences section by section. +3. Continue applying workouts, routines, PRs, and other existing entities. + +The existing `localProfiles` response remains metadata for the portal and is not promoted to a mobile profile-lifecycle protocol in this feature. Active-profile selection remains device-local and is never synchronized. + +Merge rules: + +- local wall-clock timestamps are never used to order writes across devices; +- a local edit retains its last acknowledged `baseRevision` and marks the section dirty; +- the server accepts a mutation only when `baseRevision` matches the current section revision, then increments the revision and stamps server time; +- a revision mismatch returns the canonical server section and the server value wins for the sent snapshot, subject to the in-flight local-generation rule below; +- pull applies a higher server revision to a clean local section; a dirty section is resolved by its subsequent push rather than overwritten during pull; +- equal revisions with different content are treated as an invariant violation and repaired to the canonical server value; +- an invalid section is rejected independently while valid sections continue; +- when no newer local generation exists, a valid canonical server section returned for a revision conflict replaces the dirty local section; malformed or unsupported canonical data never replaces a valid local section; +- local-only safety and consent fields never enter conversion or merge code; +- resetting a section sends its explicit default payload as a new mutation; `null` or omission means “legacy/unsupported field,” not “reset.” + +Every outbound section snapshot records its local generation in an in-memory request ledger. Response application is one SQL transaction that compares the row's current generation with the sent generation. If they match, an accepted canonical section or conflict canonical section applies normally and clears dirty state. If the current generation is newer, the response advances the stored server revision but preserves the newer local payload, timestamp, generation, and dirty state so it can be retried against that revision. Thus neither an acceptance nor a conflict response can overwrite an edit made while the request was in flight. + +`PortalSyncAdapter` maps profile preference sections for push. `PortalPullAdapter` validates and maps the canonical server sections for pull. The SQLDelight repository applies all accepted sections for one profile in a transaction. + +An older backend may ignore the optional request field without breaking workout sync. The client retains dirty local sections and does not claim they are synchronized until the new acknowledgement appears. Deployment documentation requires the database and Edge Functions to land before the mobile release. + +## Backup and restore + +Bump `CURRENT_BACKUP_VERSION` from 4 to 5 and add `profilePreferences: List` to `BackupContent`. `ProfilePreferencesBackup` contains a profile ID and five nullable syncable value sections so one corrupt section can be omitted without substituting typed defaults or dropping its valid siblings. A normal valid export includes all five. It excludes local/server timestamps, dirty flags, server revisions, the voice-stop phrase, calibration state, and adult consent/prompt state. Update both the buffered `exportAllData` path and the streaming writer/parser so they emit and consume the same v5 shape, and update the privacy metadata summary to mention profile preferences without claiming runtime secrets are present. + +Import creates or matches `UserProfile` rows before applying preference rows. A restored section is treated as a new local edit: it keeps the target row's current acknowledged server revision, receives a fresh local timestamp, and becomes dirty. Sync then follows the ordinary revision-conflict contract; backup files never transplant portal revision state between installations or accounts. + +For version 4 and older backups, the legacy top-level `equipmentRackItems` list retains its old global meaning. Track field presence separately from its decoded list: versions 1–3 normally omit it and therefore make no rack change, while a valid version-4 backup always contains it. A present non-empty list uses the existing merge-by-item-ID behavior for every profile represented by the backup that exists after profile import, or for the current active profile when the backup has no profiles. A present empty list explicitly clears those target profiles' racks. A version-4 file that omits the required field is treated as malformed for that section and leaves racks unchanged. Version-5 import uses `profilePreferences` and ignores the legacy top-level rack field. + +Profile-preference import validates each section independently. A malformed section is reported and skipped without blocking valid sections or ordinary workout data. Existing backup compatibility and partial-import accounting remain intact. + +## Supabase backend handoff + +The implementation will provide two backend artifacts under `docs/backend-handoff/`: + +- `profile-preferences-supabase.sql` +- `profile-preferences-edge-functions.md` + +The SQL contract creates `public.local_profile_preferences` with: + +- `user_id uuid NOT NULL`; +- `local_profile_id text NOT NULL`; +- a composite primary key on both columns; +- typed core/LED/VBT columns using `double precision` for measurements, `integer` for scheme IDs, and native `boolean` for flags; +- JSONB rack, workout, LED, and VBT documents; +- a non-negative `bigint` monotonically increasing server revision and server-generated `timestamptz` per section; +- an aggregate `updated_at timestamptz` maintained from section timestamps; +- a composite foreign key to the owning `local_profiles` row with delete cascade; +- ownership, JSON/range, and per-document encoded-size constraints, including a database-side 256 KiB ceiling as defense-in-depth behind Edge validation. + +The primary key order is `(user_id, local_profile_id)`, so the leading `user_id` column used by RLS and the complete composite foreign key are indexed without an extra redundant index. JSONB documents do not receive speculative GIN indexes because sync looks up rows by the primary key and replaces whole validated sections rather than querying inside the documents. + +The script begins with a preflight `DO` block that verifies the expected `local_profiles(user_id, id)` key contract and aborts with a descriptive error before making changes if the portal uses different names. This is necessary because the portal repository is not available here. + +RLS is enabled before API grants. Use four owner policies targeted with `TO authenticated`: SELECT and DELETE use `USING ((select auth.uid()) = user_id)`; INSERT uses `WITH CHECK ((select auth.uid()) = user_id)`; UPDATE includes both the same `USING` and `WITH CHECK` expressions. The SELECT policy is required for UPDATE to work, and the UPDATE check prevents ownership reassignment. Do not use `auth.role()` or user-editable metadata for authorization. + +The SQL handoff also makes Data API exposure explicit. Revoke all table privileges from `PUBLIC`, `anon`, and `authenticated`; grant the required CRUD privileges only to `service_role` for the server-side sync path. Direct authenticated INSERT/UPDATE would let a caller forge server revisions/timestamps or bypass Edge validation and request-size limits, and RLS cannot enforce column-transition invariants by itself. The owner RLS policies remain part of the schema contract and are tested as defense-in-depth, but they are not a substitute for the Edge mutation boundary. If the portal later exposes direct authenticated access, it must first move the full mutation protocol into a narrowly granted database API that enforces the same invariants; broad table writes remain prohibited. Grants and RLS are separate controls, and both decisions must be explicit because new `public` tables are no longer guaranteed automatic Data API grants. See Supabase's [2026 exposure change](https://supabase.com/changelog/45329-breaking-change-tables-not-exposed-to-data-and-graphql-api-automatically) and [current RLS guidance](https://supabase.com/docs/guides/database/postgres/row-level-security). + +The Edge Function keeps platform `verify_jwt = true`; the mobile request supplies its Supabase user JWT in `Authorization`, not a publishable/secret key as a bearer token. The handler resolves the caller with `auth.getUser(userJwt)` and derives `user_id` only from that verified user; it never trusts a user ID supplied by the mobile payload. After verification it uses a server-only secret/service-role client for the short database transaction, always includes the verified `user_id` in the primary-key predicate, and never returns or logs the secret. Because `service_role` bypasses RLS, these explicit predicates, payload validation, and cross-user Edge tests are mandatory rather than relying on RLS to protect privileged queries. See Supabase's current [Edge Function authorization-header guidance](https://supabase.com/docs/guides/functions/auth-headers). + +The Edge Function handoff specifies: + +- additive request and response types; +- revision-checked section upserts rather than whole-row replacement or device-clock ordering; +- canonical accepted/rejected sections in the push response; +- per-profile, per-section rejection details; +- pull selection and response fields; +- schema-version validation; +- 256 KiB section and 512 KiB request limits with independent-section failure; +- validation and authentication happen before the database transaction, and no network call occurs while row locks are held; +- each mutation uses one atomic conditional `UPDATE ... WHERE user_id = ? AND local_profile_id = ? AND section_revision = ? RETURNING ...`. For a missing row, only `baseRevision = 0` may create it: `INSERT ... ON CONFLICT DO NOTHING RETURNING ...` writes the mutated section at revision 1 and leaves other sections at revision 0. If that insert returns no row, the same transaction runs the ordinary revision-checked UPDATE for the target section; it never uses an unconditional `DO UPDATE`. A zero-row conditional update then fetches the canonical conflict row in the same short transaction; +- cascade/deletion behavior inherited from `local_profiles`; +- deployment ordering and required server tests; +- portal implementation creates the real migration with `supabase migration new profile_preferences`, applies it in a disposable/local environment, runs Supabase security and performance advisors, and records a clean migration-list check before deployment. + +## Navigation and profile switching + +Add `NavigationRoutes.Profile` as a root tab. The bottom-bar order is: + +1. Analytics +2. Workout +3. Insights +4. Profile +5. Settings + +The bar remains icon-only. Profile uses a person icon. Because the existing four-item geometry (`24.dp` outer padding, spaced items, and a `64.dp` minimum inner width) cannot fit five items on a narrow phone, update the row to five equal-width cells with at most `8.dp` outer padding and no fixed `64.dp` minimum. Each cell keeps a full-width clickable surface at least `48.dp` high/wide, so the five destinations fit at 320 dp without sacrificing the minimum touch target. + +The Profile navigation item uses `combinedClickable`: + +- tap navigates to `ProfileScreen` with the existing root-tab save/restore behavior; +- long press provides haptic feedback and opens `ProfileSwitcherSheet` without navigation; +- semantics expose labeled click and long-click actions with `Role.Tab`. + +`EnhancedMainScreen` owns the switcher-sheet state so it can open from any root tab where the bottom bar is visible. `ProfileScreen` also contains a visible Switch Profile action that opens the same sheet for accessibility and discoverability. + +`ProfileSwitcherSheet` contains: + +- existing profiles with avatar, name, and active indicator; +- tap-to-switch behavior; +- an Add Profile action; +- no rename or delete actions. + +Creating a profile inserts default preferences atomically, activates the new profile, and dismisses the creation flow. + +The Profile screen header owns rename/color and delete actions. The default profile cannot be deleted. Deleting another profile uses the existing data-reassignment behavior, warns the user explicitly, cleans preference/local-safety storage, and returns to Default if the active profile was removed. + +Before exposing delete on the new screen, harden the existing reassignment transaction against profile-inclusive unique-index collisions: + +- For duplicate `PersonalRecord` keys `(exerciseId, workoutMode, prType, phase)`, retain one deterministic best row. `MAX_WEIGHT` compares weight, then estimated 1RM, then achieved time; `MAX_VOLUME` compares volume, then weight, then achieved time. +- For duplicate earned badges, retain the earliest earned time and preserve a non-null celebration time when either row has one. +- Remove the losing duplicate before reassigning the remaining source rows, then run the existing gamification recomputation. + +Tests must cover deletion into a target profile that already owns overlapping PR and badge keys. + +Remove every `ProfileSidePanel` call site, including both `EnhancedMainScreen` and `JustLiftScreen`, plus the Home/root edge-swipe gesture. Delete `ProfileSidePanel` and obsolete speed-dial UI after extracting reusable profile dialogs, avatars, and colors into focused component files. Profile switching is intentionally unavailable after the user leaves a root tab for Just Lift or another workout flow; the user returns to a root tab before switching, which prevents mid-configuration profile changes. + +## Profile screen + +Use a focused `ProfileViewModel` rather than expanding `MainViewModel`. The screen is one scrolling surface with a profile header, compact insights, and grouped preferences. + +### Profile header + +- Active avatar, name, and color +- Switch Profile +- Edit name/color +- Guarded delete for non-default profiles +- Achievements entry point, because badges and gamification visibility are profile-scoped + +### Compact Exercise Insights + +- The selector opens the existing searchable/filterable `ExercisePickerDialog` with custom-exercise creation disabled. +- Selection is kept in a per-profile map for the lifetime of the current root-navigation state. +- Without a selection, choose the most recently completed exercise for that profile; profiles without history show an empty selector state. +- A profile change clears the currently rendered exercise while the new context loads, then restores that profile's still-valid saved selection. Only a profile with no saved selection resolves its most recently completed exercise. Switching back therefore restores the prior selection instead of recomputing it. + +Extract a shared current-1RM resolver used by Profile and Exercise Detail. It returns both value and source: + +1. latest passing velocity estimate for the active profile; +2. latest profile-scoped `AssessmentResult` for the exercise; +3. the most recent profile-scoped completed session's canonical hybrid estimate through `OneRepMaxCalculator`. + +The global `Exercise.one_rep_max_kg` field is not a fallback because it is not profile-scoped and could leak one profile's manual value into another profile's dashboard. + +The current assessment-save path must also receive the active profile ID explicitly. `AssessmentViewModel` and `saveAssessmentSession` may not fall back silently to `"default"`; assessment save, latest-assessment lookup, and the resolver above all use the same active profile ID. This is a prerequisite for claiming profile-correct 1RM data. + +Historical velocity estimates and assessment actions remain visible even when `vbtEnabled` is false. + +PR highlights show the applicable all-time max-weight, estimated-1RM, and volume highs for the selected movement. Recent history shows at most the latest five completed, non-deleted sessions plus a compact volume trend. View Full History opens the existing `ExerciseDetailScreen`. + +Repository queries are scoped by profile and exercise and return only the data required for the compact dashboard. Loading, empty, missing-exercise, and malformed-metric states render independently. + +### Preferences + +Compose grouped cards from extracted reusable controls: + +- Measurements +- Equipment Rack +- Workout Behavior +- LED +- VBT +- Safety + +Complex groups may open a dialog or bottom sheet, but the row always shows its current value. Typed repository methods validate values before persistence; JSON encoding stays inside the data layer. Body weight supports 0/unset and the existing 20–300 kg range with unit conversion. + +The Profile presentation applies these corrections without changing the database, document versions, or sync wire keys: + +- The nested Profile scaffold consumes no system window insets. The outer app scaffold owns them, and the scrolling content keeps 12 dp between the top bar and profile card. +- Weight Increment is an explicit selector for the step used by routine and single-exercise per-set weight controls. Kilograms offer 0.5, 1, 2.5, and 5 kg; pounds offer 0.1, 0.5, 1, 2.5, and 5 lb. A legacy stored `-1` renders as the unit default without an automatic write. Changing units writes the explicit default of 0.5 kg or 1 lb. +- Default Rest, Rep Count Timing, Stop at Top, and Stall Detection remain decodable workout/exercise defaults but are not global Profile controls. VBT auto-end no longer depends on the profile-level stall-detection value. +- Routine `% of PR` behavior is configured on each routine exercise, so the Profile screen does not expose `defaultRoutineExerciseUsePercentOfPR` or its 50–120% seeding control. The stored fields remain decodable for compatibility. +- LED selection is one horizontally scrolling radio group of eight 48 dp swatches built from the trainer's existing gradients. Off is crossed out; selection has a border, check, and visible localized name. The LED card title retains the seven-tap Disco Mode unlock. +- There is no standalone Adults Only action. Attempting to enable Vulgar Mode is the only age-confirmation entry point. Confirmation persists prompted/confirmed local safety before enabling Vulgar Mode. An under-18 response first writes Vulgar Mode and active Dominatrix Mode off, then records prompted/unconfirmed safety; partial failures remain retryable and fail closed. +- Dominatrix Mode is absent from the UI until permanently unlocked. With local adult confirmation, VBT, verbal encouragement, and Vulgar Mode all enabled, seven VBT title-bar taps within two seconds request the unlock. The whip sound and popup are emitted only from the matching successful post-commit event. Disabling VBT, verbal encouragement, or Vulgar Mode deactivates Dominatrix Mode without clearing its unlock flag. + +## Settings pruning + +Settings retains only: + +- Portal account and cloud sync +- Theme and dynamic color +- Language +- Video behavior +- Health/external integrations +- Backup, restore, destination, and destructive data management +- BLE compatibility, logs, diagnostics, and developer tools +- Donation and app information + +For integrations, the Settings entry point, platform permissions, provider authorization, and device/account diagnostics remain global. Imported measurements, profile-keyed cursors or last-sync markers, and other provider data keep their existing profile ownership. This feature does not rescope integration tables; it only ensures an imported body-weight value writes to the active profile's core preferences. + +Remove weight unit/increment, body weight, equipment rack, workout behavior, audio, gamification controls, achievements navigation, LED, VBT, verbal feedback, and voice-stop controls from Settings. + +Extract controls before moving them so neither `SettingsTab` nor `ProfileScreen` becomes another monolith. Shared components remain presentation-only and receive typed state/callbacks. + +## Failure handling + +- Local edits work offline and are immediately authoritative. +- A failed local update restores the prior UI value and presents a concise error. +- Profile switching exposes `Switching` state and blocks workout start until the new context is ready. +- Main navigation and workout actions remain behind the required-migration gate; a partial or failed copy shows Retry and is never recorded as complete. +- Profile-local Settings cleanup is journaled in SQL and retried after a partial cross-store delete. +- Invalid local JSON produces a logged section error and typed defaults without rewriting the raw value until the user makes a valid edit. +- Invalid remote JSON rejects only its section and retains valid local data. +- Oversized legacy sections remain local and visible, are never silently truncated, and report a section-specific sync diagnostic. +- Sync failures leave local data usable and retryable. +- An absent preference acknowledgement leaves preference sync pending while ordinary workout sync remains functional. + +## Testing strategy + +### Schema and migration + +- Fresh schema version 43 contains both new tables, constraints, and queries. +- Migration 42 → 43 produces the same shape as a fresh database. +- Gradle SQLDelight version, generated schema version, parity constant, fallback map, and latest migration number all agree on version 43. +- Every historical schema upgrade still converges through parity tests. +- Fallback migration and reconciliation manifest cover both new tables and all preference columns. +- A logically current database missing the preference table or some profile rows is reconciled and seeded before legacy copy. +- The cross-store migration copies one legacy snapshot to every existing profile. +- Retry and partial-failure tests prove migrated rows are not overwritten. +- A deliberately delayed migration proves Splash/root navigation cannot expose workout start before `Ready`; failure exposes Retry. +- A deliberately delayed migration proves preference sync is suppressed and health/body-weight imports are queued until `Ready`, while unrelated workout sync may continue. +- Corrupt legacy primitives, enums, rack entries, and JSON documents normalize deterministically without trapping startup in a permanent Retry loop. +- New profiles receive defaults and do not inherit the active profile. +- The active profile remains unchanged during migration. +- Migrated and newly created profiles both default `vbtEnabled` to true. + +### Repository and runtime isolation + +- Profile A/B tests cover every preference section. +- Switching emits `Switching` then one consistent `Ready` context. +- Only the edited section's local timestamp, local generation, and dirty flag change; its server revision remains unchanged until acknowledgement. +- Rack, Just Lift, per-exercise defaults, body weight, LED, and VBT consumers follow the active profile. +- Disabling VBT stops live velocity-loss evaluation/auto-end and VBT failure feedback, deactivates Dominatrix Mode while preserving its unlock and other subordinate configuration, and leaves assessments/history visible. +- Profile deletion merges overlapping PR/badge keys, removes SQL preferences, and eventually removes profile-prefixed local keys through the cleanup journal. +- A failed Settings-key cleanup remains queued and succeeds on a later startup. +- Effective voice/vulgar features remain disabled without local setup/consent. + +### Sync + +- Serialization includes all syncable fields and excludes all local-only fields. +- Dirty preference sections use preference-only chunks after profile metadata has been pushed. +- Encoded section/request limits are enforced, and a multi-profile maximum-size test stays below the existing endpoint body limit. +- Pull ignores unknown profile IDs and never creates or resurrects a local profile. +- Base-revision accept, stale-revision reject, clean pull, dirty pull, retry-after-lost-ack, and equal-revision invariant cases converge deterministically without trusting device clocks. +- Accepted and conflict responses racing a newer local edit advance the base revision without overwriting the newer payload or clearing its dirty state. +- Malformed local JSON never pushes typed fallback defaults and becomes syncable only after explicit repair/reset. +- Malformed sections do not poison valid sections. +- Older-backend responses remain decodable and do not falsely acknowledge preferences. +- Backend handoff tests prove direct `anon`/`authenticated` table DML is denied by grants; authenticated Edge calls can mutate only the verified user's rows; cross-user IDs, forged revision/timestamp fields, invalid payloads, and oversized sections are rejected; and section-wise merge/canonical acknowledgement/pull round-trips succeed. +- RLS policy tests, run in an isolated transaction with only the temporary grants needed by the test harness, prove owner SELECT/INSERT/UPDATE/DELETE predicates and ownership-reassignment checks independently of the Edge authorization tests. +- Concurrent first writes for the same profile/section produce one revision-1 winner and one canonical conflict; concurrent first writes for two different sections both succeed without overwriting either section. +- Supabase security/performance advisors are run after applying the portal migration, with every finding either fixed or explicitly dispositioned in the backend handoff. + +### Backup and restore + +- Buffered and streaming v5 exports contain identical profile-preference values and exclude local safety and sync metadata. +- A v5 round-trip restores each profile's sections as valid local edits and marks them dirty against the target row's existing revision. +- Version 1–3 files with no rack field leave current racks unchanged; a version-4 non-empty rack merges into every target profile; a present empty version-4 rack clears every target profile. +- One malformed preference section is counted and skipped without blocking valid sections or workout entities. + +### UI and navigation + +- Bottom bar has five items in the approved order. +- Five equal-width bottom-bar targets fit at 320 dp and retain at least 48 dp touch targets in normal and compact-height layouts. +- Profile tap navigates; long press opens the sheet without navigating. +- Accessibility exposes both actions. +- Create activates a default-initialized profile. +- Rename/delete live on Profile, not the switcher sheet. +- No source file—including `EnhancedMainScreen` and `JustLiftScreen`—references the side panel or speed dial. +- Settings source no longer contains migrated controls. +- Assessment save/read paths and Insights use the active profile, correct 1RM precedence, correct PRs, and at most five recent sessions. +- Switching Profile A → B → A restores A's still-valid exercise selection; a profile without a saved selection resolves its own most recent exercise. +- Profile content begins approximately 12 dp below the top bar, exercise-level rows and the standalone Adults Only action are absent, and the LED choices form one compact horizontal radio group with 48 dp targets. +- Vulgar Mode alone opens age confirmation; under-18 responses visibly reset it. Dominatrix remains absent before unlock, and the seventh eligible VBT title tap produces the committed unlock popup/sound while six taps or a two-second reset do not. +- TalkBack traverses the modified switches and LED swatches once each with their role, selected/disabled state, and localized name. + +## Acceptance criteria + +- Switching profiles updates all person-specific preferences and workout behavior without restarting. +- No profile switch can start a workout with mixed-profile context. +- Existing profiles retain current behavior after upgrade; future profiles start clean. +- Syncable preference sections round-trip through the documented portal contract with section-level conflict handling. +- The voice phrase, calibration, and consent flags never appear in sync or backup payloads. +- A v5 backup round-trip preserves all syncable profile preference values for each profile without exporting sync metadata or device-local safety values. +- Standard Profile tap and long press behave distinctly and accessibly. +- Home and Just Lift have no slide-out profile selector; profile switching is confined to root-tab UI. +- Profile presents compact, profile-correct exercise analytics and links to full history. +- Settings contains only global app/device/account/maintenance configuration. +- Mobile remains usable offline and remains compatible with an older backend for non-preference sync. + +## Delivery order + +1. Produce and review the Supabase SQL and Edge Function handoff. +2. Reconcile schema-version metadata, add schema 43, typed models, cleanup journal, awaited migration gate, and legacy migration. +3. Add profile-preference sync DTOs, adapters, merge behavior, and tests. +4. Route current consumers through the new active profile context. +5. Harden overlapping-data profile deletion, then add Profile navigation, switcher sheet, and profile management header. +6. Build compact insights and grouped preference UI. +7. Prune Settings and remove the old Home panel/speed dial. +8. Run migration, repository, sync, navigation, UI-contract, and build verification. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2c555a5ee..9da56dc5e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -175,6 +175,7 @@ ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } +ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" } diff --git a/iosApp/VitruvianPhoenix/VitruvianPhoenix.xcodeproj/project.pbxproj b/iosApp/VitruvianPhoenix/VitruvianPhoenix.xcodeproj/project.pbxproj index 76339576d..5f22c801a 100644 --- a/iosApp/VitruvianPhoenix/VitruvianPhoenix.xcodeproj/project.pbxproj +++ b/iosApp/VitruvianPhoenix/VitruvianPhoenix.xcodeproj/project.pbxproj @@ -367,7 +367,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.9.5; + MARKETING_VERSION = 0.9.6; PRODUCT_BUNDLE_IDENTIFIER = com.devil.phoenixproject.projectphoenix; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -412,7 +412,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.9.5; + MARKETING_VERSION = 0.9.6; PRODUCT_BUNDLE_IDENTIFIER = com.devil.phoenixproject.projectphoenix; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index c986c4b0e..992120a0d 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -124,6 +124,7 @@ kotlin { implementation(libs.turbine) implementation(libs.koin.test) implementation(libs.multiplatform.settings.test) + implementation(libs.ktor.client.mock) } } @@ -209,8 +210,8 @@ sqldelight { databases { create("VitruvianDatabase") { packageName.set("com.devil.phoenixproject.database") - // Version 41 = initial schema (1) + 40 migrations (1.sqm through 40.sqm). - version = 41 + // Version 43 = initial schema (1) + 42 migrations (1.sqm through 42.sqm). + version = 43 } } } diff --git a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/local/SchemaManifestTest.kt b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/local/SchemaManifestTest.kt index 270d998b7..9dc01c6db 100644 --- a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/local/SchemaManifestTest.kt +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/local/SchemaManifestTest.kt @@ -3,6 +3,7 @@ package com.devil.phoenixproject.data.local import app.cash.sqldelight.db.QueryResult import app.cash.sqldelight.db.SqlDriver import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver +import com.devil.phoenixproject.database.VitruvianDatabase import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -161,6 +162,66 @@ class SchemaManifestTest { assertEquals(ReconciliationStatus.ALREADY_PRESENT, result.status) } + @Test + fun `reconcileFullSchema restores profile preference and cleanup tables with all columns`() { + val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) + VitruvianDatabase.Schema.create(driver) + driver.execute(null, "DROP TABLE IF EXISTS UserProfilePreferences", 0) + driver.execute(null, "DROP TABLE IF EXISTS PendingProfileContextRecovery", 0) + driver.execute(null, "DROP TABLE IF EXISTS PendingProfileLocalCleanup", 0) + + reconcileFullSchema(driver) + + assertTrue(tableExists(driver, "UserProfilePreferences")) + assertTrue(tableExists(driver, "PendingProfileContextRecovery")) + assertTrue(tableExists(driver, "PendingProfileLocalCleanup")) + assertEquals( + listOf( + "profile_id", + "schema_version", + "legacy_migration_version", + "body_weight_kg", + "weight_unit", + "weight_increment", + "core_updated_at", + "core_local_generation", + "core_server_revision", + "core_dirty", + "equipment_rack_json", + "rack_updated_at", + "rack_local_generation", + "rack_server_revision", + "rack_dirty", + "workout_preferences_json", + "workout_updated_at", + "workout_local_generation", + "workout_server_revision", + "workout_dirty", + "led_color_scheme_id", + "led_preferences_json", + "led_updated_at", + "led_local_generation", + "led_server_revision", + "led_dirty", + "vbt_enabled", + "vbt_preferences_json", + "vbt_updated_at", + "vbt_local_generation", + "vbt_server_revision", + "vbt_dirty", + ), + columnNames(driver, "UserProfilePreferences"), + ) + assertEquals( + listOf("recovery_key", "prior_profile_id", "created_profile_id", "enqueued_at"), + columnNames(driver, "PendingProfileContextRecovery"), + ) + assertEquals( + listOf("profile_id", "enqueued_at"), + columnNames(driver, "PendingProfileLocalCleanup"), + ) + } + // ── Index Create Tests ────────────────────────────────────────────── @Test diff --git a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/local/SchemaParityTest.kt b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/local/SchemaParityTest.kt index b0e5ecd40..10784eda9 100644 --- a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/local/SchemaParityTest.kt +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/local/SchemaParityTest.kt @@ -33,7 +33,7 @@ class SchemaParityTest { // Database B: v1 base -> migrate 1..27 with resilient fallback -> reconcile val upgradeDriver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) buildMinimalV1Schema(upgradeDriver) - migrateWithResilience(upgradeDriver, 1, CURRENT_VERSION) + migrateWithResilience(upgradeDriver, 1, EXPECTED_SCHEMA_VERSION) reconcileFullSchema(upgradeDriver) // Compare tables (filter out migration temp/rebuild artifacts) @@ -106,30 +106,30 @@ class SchemaParityTest { fun `every intermediate version upgrades cleanly with all manifest entries`() { val failures = mutableListOf() - for (startVersion in 1L..CURRENT_VERSION - 1) { + for (startVersion in 1L..EXPECTED_SCHEMA_VERSION - 1) { val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) try { buildSchemaAtVersion(driver, startVersion) - if (startVersion < CURRENT_VERSION) { - migrateWithResilience(driver, startVersion, CURRENT_VERSION) + if (startVersion < EXPECTED_SCHEMA_VERSION) { + migrateWithResilience(driver, startVersion, EXPECTED_SCHEMA_VERSION) } reconcileFullSchema(driver) // Verify all manifestColumns entries exist for (op in manifestColumns) { if (!columnExistsInDriver(driver, op.table, op.column)) { - failures += "v$startVersion->v$CURRENT_VERSION: MISSING column ${op.table}.${op.column}" + failures += "v$startVersion->v$EXPECTED_SCHEMA_VERSION: MISSING column ${op.table}.${op.column}" } } // Verify all manifestIndexes entries exist for (op in manifestIndexes) { if (!indexExistsInDriver(driver, op.name)) { - failures += "v$startVersion->v$CURRENT_VERSION: MISSING index ${op.name}" + failures += "v$startVersion->v$EXPECTED_SCHEMA_VERSION: MISSING index ${op.name}" } } } catch (e: Exception) { - failures += "v$startVersion->v$CURRENT_VERSION: EXCEPTION ${e::class.simpleName}: ${e.message}" + failures += "v$startVersion->v$EXPECTED_SCHEMA_VERSION: EXCEPTION ${e::class.simpleName}: ${e.message}" } finally { try { driver.close() @@ -595,10 +595,53 @@ class SchemaParityTest { assertEquals(null, queryScalar(driver, "SELECT template_id FROM TrainingCycle WHERE id = 'cycle-531'")) } + @Test + fun `generated schema exposes expected version`() { + assertEquals(EXPECTED_SCHEMA_VERSION, VitruvianDatabase.Schema.version) + } + + @Test + fun migration42To43AddsProfilePreferenceTablesAndSeedsProfiles() { + val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) + buildSchemaAtVersion(driver, 42) + driver.execute( + null, + "INSERT INTO UserProfile(id,name,colorIndex,createdAt,isActive) VALUES('a','A',0,1,1)", + 0, + ) + + VitruvianDatabase.Schema.migrate(driver, 42, EXPECTED_SCHEMA_VERSION) + + assertEquals("1", queryScalar(driver, "SELECT CAST(COUNT(*) AS TEXT) FROM UserProfilePreferences WHERE profile_id = 'a'")) + assertEquals("1", queryScalar(driver, "SELECT CAST(vbt_enabled AS TEXT) FROM UserProfilePreferences WHERE profile_id = 'a'")) + assertEquals("0", queryScalar(driver, "SELECT CAST(legacy_migration_version AS TEXT) FROM UserProfilePreferences WHERE profile_id = 'a'")) + assertEquals(true, getTables(driver).contains("PendingProfileLocalCleanup")) + assertEquals(true, getTables(driver).contains("PendingProfileContextRecovery")) + } + + @Test + fun `resilient migration 42 fallback adds profile preference tables and seeds profiles`() { + val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) + buildSchemaAtVersion(driver, 42) + driver.execute( + null, + "INSERT INTO UserProfile(id,name,colorIndex,createdAt,isActive) VALUES('fallback','Fallback',0,1,1)", + 0, + ) + + applyMigrationResilient(driver, 42) + + assertEquals("1", queryScalar(driver, "SELECT CAST(COUNT(*) AS TEXT) FROM UserProfilePreferences WHERE profile_id = 'fallback'")) + assertEquals("1", queryScalar(driver, "SELECT CAST(vbt_enabled AS TEXT) FROM UserProfilePreferences WHERE profile_id = 'fallback'")) + assertEquals("0", queryScalar(driver, "SELECT CAST(legacy_migration_version AS TEXT) FROM UserProfilePreferences WHERE profile_id = 'fallback'")) + assertEquals(true, getTables(driver).contains("PendingProfileLocalCleanup")) + assertEquals(true, getTables(driver).contains("PendingProfileContextRecovery")) + } + // ==================== HELPERS ==================== companion object { - private const val CURRENT_VERSION = 42L + private const val EXPECTED_SCHEMA_VERSION = 43L private val CANONICAL_UUID_REGEX = Regex("^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$") /** diff --git a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/migration/MigrationManagerTest.kt b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/migration/MigrationManagerTest.kt index 15b69666b..cc60044fc 100644 --- a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/migration/MigrationManagerTest.kt +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/migration/MigrationManagerTest.kt @@ -1,13 +1,24 @@ package com.devil.phoenixproject.data.migration +import app.cash.sqldelight.db.SqlDriver import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver +import com.devil.phoenixproject.StartupSurface +import com.devil.phoenixproject.startupSurface +import com.devil.phoenixproject.data.preferences.SettingsLegacyProfilePreferencesReader +import com.devil.phoenixproject.data.preferences.SettingsPreferencesManager +import com.devil.phoenixproject.data.preferences.SettingsProfileLocalSafetyStore +import com.devil.phoenixproject.data.repository.SqlDelightGamificationRepository +import com.devil.phoenixproject.data.repository.SqlDelightProfilePreferencesRepository +import com.devil.phoenixproject.data.repository.SqlDelightUserProfileRepository import com.devil.phoenixproject.database.VitruvianDatabase import com.devil.phoenixproject.domain.model.PRType import com.devil.phoenixproject.testutil.createTestDatabase import com.devil.phoenixproject.util.OneRepMaxCalculator +import com.russhwolf.settings.MapSettings import kotlinx.coroutines.test.runTest import kotlin.test.assertEquals import kotlin.test.assertNotNull +import kotlin.test.assertNotEquals import kotlin.test.assertNull import org.junit.Before import org.junit.Test @@ -20,7 +31,60 @@ class MigrationManagerTest { @Before fun setup() { database = createTestDatabase() - migrationManager = MigrationManager(database) + migrationManager = createMigrationManager(database) + } + + private fun createMigrationManager( + database: VitruvianDatabase, + driver: SqlDriver? = null, + settings: MapSettings = MapSettings(), + ): MigrationManager { + val preferences = SqlDelightProfilePreferencesRepository(database) + val safety = SettingsProfileLocalSafetyStore(settings) + val gamification = SqlDelightGamificationRepository(database) + val profiles = SqlDelightUserProfileRepository( + database = database, + profilePreferencesRepository = preferences, + profileLocalSafetyStore = safety, + gamificationRepository = gamification, + ) + return MigrationManager( + database = database, + userProfileRepository = profiles, + gamificationRepository = gamification, + settings = settings, + profilePreferencesRepository = preferences, + profileLocalSafetyStore = safety, + legacyProfilePreferencesReader = SettingsLegacyProfilePreferencesReader( + SettingsPreferencesManager(settings), + settings, + ), + driver = driver, + ) + } + + @Test + fun `startup surface never exposes main before required migration is ready`() { + assertEquals( + StartupSurface.EULA, + startupSurface(false, true, RequiredMigrationState.Ready), + ) + assertNotEquals( + StartupSurface.MAIN, + startupSurface(true, true, RequiredMigrationState.Applying), + ) + assertEquals( + StartupSurface.MIGRATION_RETRY, + startupSurface(true, true, RequiredMigrationState.Failed("retry")), + ) + assertNotEquals( + StartupSurface.MAIN, + startupSurface(true, false, RequiredMigrationState.Ready), + ) + assertEquals( + StartupSurface.MAIN, + startupSurface(true, true, RequiredMigrationState.Ready), + ) } // -- cleanupFabricatedRoutineSessionIds tests -- @@ -356,7 +420,7 @@ class MigrationManagerTest { val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) VitruvianDatabase.Schema.create(driver) val localDatabase = VitruvianDatabase(driver) - val localMigrationManager = MigrationManager(localDatabase, driver = driver) + val localMigrationManager = createMigrationManager(localDatabase, driver) val queries = localDatabase.vitruvianDatabaseQueries val stableUuid = "12345678-1234-4abc-8def-1234567890ab" @@ -452,6 +516,241 @@ class MigrationManagerTest { ) } + @Test + fun `repair version two normalizes aliases without losing ids names or sync metadata`() = runTest { + val settings = MapSettings().apply { + putInt("migration_repair_version", 1) + } + val localManager = createMigrationManager(database, settings = settings) + val queries = database.vitruvianDatabaseQueries + + queries.insertRecord( + exerciseId = "deadlift", + exerciseName = "Target Deadlift", + weight = 100.0, + reps = 10, + oneRepMax = 130.0, + achievedAt = 300, + workoutMode = "Old School", + prType = PRType.MAX_VOLUME.name, + volume = 1_000.0, + phase = "COMBINED", + profile_id = "default", + cable_count = 2, + uuid = "target-volume-uuid", + ) + val targetVolume = queries.selectAllRecords("default").executeAsList().single() + queries.updatePRServerId("target-volume-server", targetVolume.id) + queries.updatePRTimestamp(10, listOf(targetVolume.id)) + queries.insertRecord( + exerciseId = "deadlift", + exerciseName = "", + weight = 101.0, + reps = 10, + oneRepMax = 120.0, + achievedAt = 100, + workoutMode = "OldSchool", + prType = PRType.MAX_VOLUME.name, + volume = 1_000.0, + phase = "COMBINED", + profile_id = "default", + cable_count = 1, + uuid = "source-volume-uuid", + ) + val sourceVolume = queries.selectAllRecords("default").executeAsList() + .single { it.workoutMode == "OldSchool" } + queries.updatePRServerId("source-volume-server", sourceVolume.id) + queries.updatePRTimestamp(20, listOf(sourceVolume.id)) + + queries.insertRecord( + exerciseId = "bench", + exerciseName = "Target Bench", + weight = 50.0, + reps = 5, + oneRepMax = 60.0, + achievedAt = 100, + workoutMode = "Old School", + prType = PRType.MAX_WEIGHT.name, + volume = 250.0, + phase = "COMBINED", + profile_id = "default", + cable_count = null, + uuid = "target-fallback-uuid", + ) + val targetBench = queries.selectAllRecords("default").executeAsList() + .single { it.exerciseId == "bench" } + queries.insertRecord( + exerciseId = "bench", + exerciseName = "", + weight = 70.0, + reps = 5, + oneRepMax = 80.0, + achievedAt = 200, + workoutMode = "OldSchool", + prType = PRType.MAX_WEIGHT.name, + volume = 350.0, + phase = "COMBINED", + profile_id = "default", + cable_count = null, + uuid = null, + ) + + localManager.runMigrationsNow() + + val volumeRows = queries.selectAllRecords("default").executeAsList() + .filter { it.exerciseId == "deadlift" } + assertEquals(1, volumeRows.size) + val volume = volumeRows.single() + assertEquals(targetVolume.id, volume.id) + assertEquals("Old School", volume.workoutMode) + assertEquals(101.0, volume.weight) + assertEquals("Target Deadlift", volume.exerciseName) + assertEquals("source-volume-uuid", volume.uuid) + assertEquals("source-volume-server", volume.serverId) + assertEquals(20, volume.updatedAt) + val bench = queries.selectAllRecords("default").executeAsList() + .single { it.exerciseId == "bench" } + assertEquals(targetBench.id, bench.id) + assertEquals("Old School", bench.workoutMode) + assertEquals(70.0, bench.weight) + assertEquals("Target Bench", bench.exerciseName) + assertEquals("target-fallback-uuid", bench.uuid) + assertEquals(2, settings.getInt("migration_repair_version", 0)) + } + + @Test + fun `orphan repair preserves target ids and deterministic PR badge sync metadata`() = runTest { + val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) + VitruvianDatabase.Schema.create(driver) + val localDatabase = VitruvianDatabase(driver) + val localMigrationManager = createMigrationManager(localDatabase, driver) + val queries = localDatabase.vitruvianDatabaseQueries + queries.insertProfile("target-profile", "Target", 0, 1, 1) + + queries.insertRecord( + exerciseId = "deadlift", + exerciseName = "Target Deadlift", + weight = 100.0, + reps = 10, + oneRepMax = 130.0, + achievedAt = 300, + workoutMode = "Old School", + prType = PRType.MAX_VOLUME.name, + volume = 1_000.0, + phase = "COMBINED", + profile_id = "target-profile", + cable_count = 2, + uuid = "target-volume-uuid", + ) + val targetVolume = queries.selectAllRecords("target-profile").executeAsList().single() + queries.updatePRServerId("target-volume-server", targetVolume.id) + queries.updatePRTimestamp(10, listOf(targetVolume.id)) + queries.insertRecord( + exerciseId = "deadlift", + exerciseName = "", + weight = 101.0, + reps = 10, + oneRepMax = 120.0, + achievedAt = 100, + workoutMode = "OldSchool", + prType = PRType.MAX_VOLUME.name, + volume = 1_000.0, + phase = "COMBINED", + profile_id = "orphan-profile", + cable_count = 1, + uuid = "source-volume-uuid", + ) + val sourceVolume = queries.selectAllRecords("orphan-profile").executeAsList().single() + queries.updatePRServerId("source-volume-server", sourceVolume.id) + queries.updatePRTimestamp(20, listOf(sourceVolume.id)) + + queries.insertRecord( + exerciseId = "bench", + exerciseName = "Bench", + weight = 50.0, + reps = 5, + oneRepMax = 60.0, + achievedAt = 100, + workoutMode = "Old School", + prType = PRType.MAX_WEIGHT.name, + volume = 250.0, + phase = "COMBINED", + profile_id = "target-profile", + cable_count = null, + uuid = "target-fallback-uuid", + ) + val targetBench = queries.selectAllRecords("target-profile").executeAsList() + .single { it.exerciseId == "bench" } + queries.insertRecord( + exerciseId = "bench", + exerciseName = "Bench", + weight = 70.0, + reps = 5, + oneRepMax = 80.0, + achievedAt = 200, + workoutMode = "OldSchool", + prType = PRType.MAX_WEIGHT.name, + volume = 350.0, + phase = "COMBINED", + profile_id = "orphan-profile", + cable_count = null, + uuid = null, + ) + + queries.insertEarnedBadgeFullIgnore( + badgeId = "shared-badge", + earnedAt = 200, + celebratedAt = 250, + updatedAt = 20, + serverId = "target-badge-server", + deletedAt = null, + profile_id = "target-profile", + ) + val targetBadgeId = queries.selectAllEarnedBadges("target-profile").executeAsOne().id + queries.insertEarnedBadgeFullIgnore( + badgeId = "shared-badge", + earnedAt = 100, + celebratedAt = 300, + updatedAt = 10, + serverId = "source-badge-server", + deletedAt = 400, + profile_id = "orphan-profile", + ) + + assertEquals(2, localMigrationManager.repairOrphanedPRRecords("target-profile")) + + val volume = queries.selectPR( + "deadlift", + "Old School", + PRType.MAX_VOLUME.name, + "COMBINED", + "target-profile", + ).executeAsOne() + assertEquals(targetVolume.id, volume.id) + assertEquals(101.0, volume.weight) + assertEquals("Target Deadlift", volume.exerciseName) + assertEquals("source-volume-uuid", volume.uuid) + assertEquals("source-volume-server", volume.serverId) + assertEquals(20, volume.updatedAt) + val bench = queries.selectPR( + "bench", + "Old School", + PRType.MAX_WEIGHT.name, + "COMBINED", + "target-profile", + ).executeAsOne() + assertEquals(targetBench.id, bench.id) + assertEquals(70.0, bench.weight) + assertEquals("target-fallback-uuid", bench.uuid) + val badge = queries.selectEarnedBadgeById("shared-badge", "target-profile").executeAsOne() + assertEquals(targetBadgeId, badge.id) + assertEquals(100, badge.earnedAt) + assertEquals(250, badge.celebratedAt) + assertEquals(20, badge.updatedAt) + assertEquals("target-badge-server", badge.serverId) + assertNull(badge.deletedAt) + } + @Test fun `repair personal records from workout history backfills achieved load and stays idempotent`() { val queries = database.vitruvianDatabaseQueries diff --git a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/migration/ProfilePreferencesMigrationTest.kt b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/migration/ProfilePreferencesMigrationTest.kt new file mode 100644 index 000000000..1220b3a46 --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/migration/ProfilePreferencesMigrationTest.kt @@ -0,0 +1,304 @@ +package com.devil.phoenixproject.data.migration + +import com.devil.phoenixproject.data.preferences.ProfileLocalSafetyStore +import com.devil.phoenixproject.data.preferences.SettingsLegacyProfilePreferencesReader +import com.devil.phoenixproject.data.preferences.SettingsPreferencesManager +import com.devil.phoenixproject.data.preferences.SettingsProfileLocalSafetyStore +import com.devil.phoenixproject.data.repository.ActiveProfileContext +import com.devil.phoenixproject.data.repository.ProfilePreferencesRepository +import com.devil.phoenixproject.data.repository.SqlDelightGamificationRepository +import com.devil.phoenixproject.data.repository.SqlDelightProfilePreferencesRepository +import com.devil.phoenixproject.data.repository.SqlDelightUserProfileRepository +import com.devil.phoenixproject.data.repository.UserProfileRepository +import com.devil.phoenixproject.database.VitruvianDatabase +import com.devil.phoenixproject.domain.model.CoreProfilePreferences +import com.devil.phoenixproject.domain.model.ProfileLocalSafetyPreferences +import com.devil.phoenixproject.domain.model.WeightUnit +import com.devil.phoenixproject.testutil.createTestDatabase +import com.russhwolf.settings.MapSettings +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout +import org.junit.Test + +class ProfilePreferencesMigrationTest { + @Test + fun legacySnapshotCopiesToEveryExistingProfileWithoutChangingActiveProfile() = runTest { + val fixture = fixture { settings -> + settings.putString("weight_unit", "KG") + settings.putFloat("body_weight_kg", 82f) + settings.putFloat("weight_increment", 2.5f) + settings.putInt("color_scheme", 3) + settings.putBoolean("safe_word_calibrated", true) + settings.putString("safe_word", "phoenix") + settings.putString( + "equipment_rack_items_v1", + """[{"id":"plate","name":"Plate","weightKg":10.0}]""", + ) + } + fixture.createProfiles("a", "b") + fixture.queries.setActiveProfile("b") + fixture.profiles.refreshProfiles() + val activeBefore = fixture.profiles.activeProfile.value?.id + + fixture.migration.runRequiredMigrations() + + assertEquals(RequiredMigrationState.Ready, fixture.migration.requiredMigrationState.value) + assertEquals(activeBefore, fixture.profiles.activeProfile.value?.id) + assertEquals("b", assertIs(fixture.profiles.activeProfileContext.value).profile.id) + listOf("a", "b").forEach { profileId -> + val preferences = fixture.preferenceRepository.get(profileId) + assertEquals(1, preferences.legacyMigrationVersion) + assertEquals(82f, preferences.core.value.bodyWeightKg) + assertEquals(WeightUnit.KG, preferences.core.value.weightUnit) + assertEquals(2.5f, preferences.core.value.weightIncrement) + assertEquals(listOf("plate"), preferences.rack.value.items.map { it.id }) + assertEquals(3, preferences.led.value.colorScheme) + assertTrue(preferences.vbt.value.enabled) + assertEquals( + ProfileLocalSafetyPreferences("phoenix", true, false, false), + fixture.safetyStore.read(profileId), + ) + } + assertTrue(fixture.settings.getBoolean("profile_preferences_legacy_migration_complete_v1", false)) + } + + @Test + fun partialFailureRetriesWithoutOverwritingCompletedRows() = runTest { + val fixture = fixture() + fixture.createProfiles("a", "b") + fixture.safetyStore.failProfileId = "b" + + fixture.migration.runRequiredMigrations() + assertIs(fixture.migration.requiredMigrationState.value) + fixture.preferenceRepository.updateCore( + "a", + CoreProfilePreferences(bodyWeightKg = 90f), + 300, + ) + + fixture.safetyStore.failProfileId = null + fixture.migration.retryRequiredMigrations() + + assertEquals(90f, fixture.preferenceRepository.get("a").core.value.bodyWeightKg) + assertEquals(1, fixture.preferenceRepository.get("a").legacyMigrationVersion) + assertEquals(1, fixture.preferenceRepository.get("b").legacyMigrationVersion) + assertEquals(RequiredMigrationState.Ready, fixture.migration.requiredMigrationState.value) + assertEquals(2, fixture.settings.getInt("migration_repair_version", 0)) + } + + @Test + fun failedRequiredMigrationUnblocksAwaitersWithTheFailure() = runTest { + val fixture = fixture() + fixture.createProfiles("a") + fixture.safetyStore.failProfileId = "a" + fixture.migration.runRequiredMigrations() + val failedState = assertIs( + fixture.migration.requiredMigrationState.value, + ) + + val failure = assertFailsWith { + withTimeout(100) { + fixture.migration.awaitRequiredMigrations() + } + } + + assertEquals(failedState.message, failure.message) + } + + @Test + fun startupReconciliationDrainsInterruptedCreateBeforeLegacyCopyAndReady() = runTest { + val fixture = fixture() + fixture.createProfiles("failed-create") + fixture.preferenceRepository.insertDefaults("failed-create") + fixture.queries.enqueueProfileContextRecovery("default", "failed-create", 100) + fixture.queries.setActiveProfile("failed-create") + + fixture.migration.runRequiredMigrations() + + assertNull(fixture.queries.selectPendingProfileContextRecovery().executeAsOneOrNull()) + assertNull(fixture.queries.getProfileById("failed-create").executeAsOneOrNull()) + assertNull(fixture.queries.selectProfilePreferences("failed-create").executeAsOneOrNull()) + assertEquals("default", fixture.queries.getActiveProfile().executeAsOne().id) + assertEquals( + "default", + assertIs(fixture.profiles.activeProfileContext.value).profile.id, + ) + assertEquals(RequiredMigrationState.Ready, fixture.migration.requiredMigrationState.value) + } + + @Test + fun activeContextStaysSwitchingUntilPreferenceAndLocalSafetyCopyFinish() = runTest { + val fixture = fixture() + val copyStarted = CompletableDeferred() + val allowCopyToFinish = CompletableDeferred() + fixture.safetyStore.beforeFirstCopy = { + copyStarted.complete(Unit) + allowCopyToFinish.await() + } + + val migrationJob = launch { fixture.migration.runRequiredMigrations() } + val readyAwaited = CompletableDeferred() + val awaitJob = launch { + fixture.migration.awaitRequiredMigrations() + readyAwaited.complete(Unit) + } + copyStarted.await() + + assertIs(fixture.profiles.activeProfileContext.value) + assertEquals(RequiredMigrationState.Applying, fixture.migration.requiredMigrationState.value) + assertFalse(readyAwaited.isCompleted) + + allowCopyToFinish.complete(Unit) + migrationJob.join() + awaitJob.join() + assertIs(fixture.profiles.activeProfileContext.value) + assertTrue(readyAwaited.isCompleted) + } + + @Test + fun profilesCreatedAfterMigrationStartWithDefaultsAndDoNotReceiveLegacyValues() = runTest { + val fixture = fixture { settings -> settings.putFloat("body_weight_kg", 90f) } + fixture.migration.runRequiredMigrations() + + val created = fixture.profiles.createProfile("New", 1) + val preferences = fixture.preferenceRepository.get(created.id) + + assertEquals(1, preferences.legacyMigrationVersion) + assertEquals(CoreProfilePreferences(), preferences.core.value) + } + + @Test + fun freshMigrationManagerDrainsCommittedDeletionCleanupJournalOnRestart() = runTest { + val fixture = fixture() + fixture.migration.runRequiredMigrations() + val targetSafety = ProfileLocalSafetyPreferences("target-secret", false, true, false) + fixture.profiles.updateLocalSafety("default", targetSafety) + val source = fixture.profiles.createAndActivateProfile("Source", 1) + val sourceSafety = ProfileLocalSafetyPreferences("source-secret", true, true, true) + fixture.profiles.updateLocalSafety(source.id, sourceSafety) + fixture.safetyStore.failDeleteProfileId = source.id + + assertTrue(fixture.profiles.deleteProfile(source.id)) + + assertNull(fixture.queries.getProfileById(source.id).executeAsOneOrNull()) + assertEquals(sourceSafety, fixture.safetyStore.read(source.id)) + assertEquals( + source.id, + fixture.queries.selectPendingProfileLocalCleanup().executeAsOne().profile_id, + ) + + fixture.safetyStore.failDeleteProfileId = null + val gamification = SqlDelightGamificationRepository(fixture.database) + val freshMigration = MigrationManager( + database = fixture.database, + userProfileRepository = fixture.profiles, + gamificationRepository = gamification, + settings = fixture.settings, + profilePreferencesRepository = fixture.preferenceRepository, + profileLocalSafetyStore = fixture.safetyStore, + legacyProfilePreferencesReader = SettingsLegacyProfilePreferencesReader( + SettingsPreferencesManager(fixture.settings), + fixture.settings, + ), + ) + + freshMigration.runRequiredMigrations() + + assertEquals(RequiredMigrationState.Ready, freshMigration.requiredMigrationState.value) + assertEquals(ProfileLocalSafetyPreferences(), fixture.safetyStore.read(source.id)) + assertEquals(targetSafety, fixture.safetyStore.read("default")) + assertTrue(fixture.queries.selectPendingProfileLocalCleanup().executeAsList().isEmpty()) + listOf( + "safe_word", + "safe_word_calibrated", + "adults_only_confirmed", + "adults_only_prompted", + ).forEach { suffix -> + assertFalse(fixture.settings.hasKey("profile_${source.id}_$suffix"), suffix) + } + } + + private fun fixture(configure: (MapSettings) -> Unit = {}): Fixture { + val database = createTestDatabase() + val settings = MapSettings().also(configure) + val preferences = SqlDelightProfilePreferencesRepository(database) + val safetyStore = FaultingProfileLocalSafetyStore(SettingsProfileLocalSafetyStore(settings)) + val gamification = SqlDelightGamificationRepository(database) + val profiles = SqlDelightUserProfileRepository( + database = database, + profilePreferencesRepository = preferences, + profileLocalSafetyStore = safetyStore, + gamificationRepository = gamification, + ) + val reader = SettingsLegacyProfilePreferencesReader( + SettingsPreferencesManager(settings), + settings, + ) + val migration = MigrationManager( + database = database, + userProfileRepository = profiles, + gamificationRepository = gamification, + settings = settings, + profilePreferencesRepository = preferences, + profileLocalSafetyStore = safetyStore, + legacyProfilePreferencesReader = reader, + ) + return Fixture(database, settings, preferences, safetyStore, profiles, migration) + } + + private data class Fixture( + val database: VitruvianDatabase, + val settings: MapSettings, + val preferenceRepository: ProfilePreferencesRepository, + val safetyStore: FaultingProfileLocalSafetyStore, + val profiles: UserProfileRepository, + val migration: MigrationManager, + ) { + val queries = database.vitruvianDatabaseQueries + + suspend fun createProfiles(vararg ids: String) { + ids.forEachIndexed { index, id -> + queries.insertProfile(id, id, index.toLong(), 100L + index, 0) + } + profiles.refreshProfiles() + } + } + + private class InjectedSafetyCopyFailure(profileId: String) : + IllegalStateException("injected safety copy failure for $profileId") + + private class FaultingProfileLocalSafetyStore( + private val delegate: ProfileLocalSafetyStore, + ) : ProfileLocalSafetyStore by delegate { + var failProfileId: String? = null + var failDeleteProfileId: String? = null + var beforeFirstCopy: (suspend () -> Unit)? = null + + override suspend fun copyLegacyToProfiles( + profileIds: List, + value: ProfileLocalSafetyPreferences, + ) { + beforeFirstCopy?.let { hook -> + beforeFirstCopy = null + hook() + } + profileIds.forEach { profileId -> + if (profileId == failProfileId) throw InjectedSafetyCopyFailure(profileId) + delegate.write(profileId, value) + } + } + + override fun delete(profileId: String) { + if (profileId == failDeleteProfileId) throw InjectedSafetyCopyFailure(profileId) + delegate.delete(profileId) + } + } +} diff --git a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightAssessmentRepositoryTest.kt b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightAssessmentRepositoryTest.kt index b4df4533d..b14998710 100644 --- a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightAssessmentRepositoryTest.kt +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightAssessmentRepositoryTest.kt @@ -1,14 +1,28 @@ package com.devil.phoenixproject.data.repository import com.devil.phoenixproject.database.VitruvianDatabase +import com.devil.phoenixproject.domain.model.WorkoutSession import com.devil.phoenixproject.testutil.createTestDatabase +import java.util.concurrent.atomic.AtomicInteger +import kotlin.coroutines.cancellation.CancellationException import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test class SqlDelightAssessmentRepositoryTest { - private lateinit var database: VitruvianDatabase private lateinit var exerciseRepository: SqlDelightExerciseRepository private lateinit var workoutRepository: SqlDelightWorkoutRepository @@ -17,46 +31,478 @@ class SqlDelightAssessmentRepositoryTest { @Before fun setup() { database = createTestDatabase() - exerciseRepository = SqlDelightExerciseRepository(database, com.devil.phoenixproject.data.local.ExerciseImporter(database)) + exerciseRepository = SqlDelightExerciseRepository( + database, + com.devil.phoenixproject.data.local.ExerciseImporter(database), + ) workoutRepository = SqlDelightWorkoutRepository(database, exerciseRepository) - repository = SqlDelightAssessmentRepository(database, workoutRepository, exerciseRepository) + repository = SqlDelightAssessmentRepository( + database, + workoutRepository, + exerciseRepository, + ) insertExercise(id = "bench-press", name = "Bench Press") } @Test - fun `saveAssessmentSession stores per-cable 1RM from total assessment estimate`() = runTest { - repository.saveAssessmentSession( + fun `saveAssessmentSession keeps estimate total and stores session and exercise per cable`() = + runTest { + val sessionId = repository.saveSessionForTest( + estimatedOneRepMaxKg = 100f, + userOverrideKg = null, + profileId = "athlete-a", + ) + + val session = workoutRepository.getSession(sessionId) + val assessment = repository.getLatestAssessment("bench-press", "athlete-a") + assertEquals("athlete-a", session?.profileId) + assertEquals(30f, session?.weightPerCableKg) + assertEquals(100f, assessment?.estimatedOneRepMaxKg) + assertNull(assessment?.userOverrideKg) + assertEquals(sessionId, assessment?.assessmentSessionId) + assertEquals( + 50f, + exerciseRepository.getExerciseById("bench-press")?.oneRepMaxKg, + ) + } + + @Test + fun `saveAssessmentSession keeps override total and updates exercise per cable`() = + runTest { + val sessionId = repository.saveSessionForTest( + estimatedOneRepMaxKg = 100f, + userOverrideKg = 120f, + profileId = "athlete-a", + ) + + val assessment = repository.getLatestAssessment("bench-press", "athlete-a") + assertEquals(100f, assessment?.estimatedOneRepMaxKg) + assertEquals(120f, assessment?.userOverrideKg) + assertEquals(sessionId, assessment?.assessmentSessionId) + assertEquals( + 60f, + exerciseRepository.getExerciseById("bench-press")?.oneRepMaxKg, + ) + } + + @Test + fun `latest assessment is isolated by explicit profile`() = runTest { + repository.saveAssessment( exerciseId = "bench-press", - exerciseName = "Bench Press", estimatedOneRepMaxKg = 100f, loadVelocityDataJson = "[]", + sessionId = null, userOverrideKg = null, - totalReps = 9, - durationMs = 60_000L, - weightPerCableKg = 30f, + profileId = "athlete-a", + ) + repository.saveAssessment( + exerciseId = "bench-press", + estimatedOneRepMaxKg = 140f, + loadVelocityDataJson = "[]", + sessionId = null, + userOverrideKg = null, + profileId = "athlete-b", ) - val exercise = exerciseRepository.getExerciseById("bench-press") - assertEquals(50f, exercise?.oneRepMaxKg, "Total 100kg assessment must persist as 50kg per cable") + assertEquals( + 100f, + repository.getLatestAssessment("bench-press", "athlete-a") + ?.estimatedOneRepMaxKg, + ) + assertEquals( + 140f, + repository.getLatestAssessment("bench-press", "athlete-b") + ?.estimatedOneRepMaxKg, + ) } @Test - fun `saveAssessmentSession converts user override total to per-cable 1RM`() = runTest { - repository.saveAssessmentSession( - exerciseId = "bench-press", - exerciseName = "Bench Press", - estimatedOneRepMaxKg = 100f, - loadVelocityDataJson = "[]", - userOverrideKg = 120f, - totalReps = 9, - durationMs = 60_000L, - weightPerCableKg = 30f, + fun `blank profile IDs are rejected before assessment writes`() = runTest { + assertFailsWith { + repository.saveAssessment( + exerciseId = "bench-press", + estimatedOneRepMaxKg = 100f, + loadVelocityDataJson = "[]", + sessionId = null, + userOverrideKg = null, + profileId = " ", + ) + } + assertFailsWith { + repository.saveSessionForTest(profileId = " ") + } + + assertNull(repository.getLatestAssessment("bench-press", " ")) + assertEquals(emptyList(), workoutRepository.getAllSessions(" ").first()) + } + + @Test + fun `ordinary post-write failure removes rows and restores prior per-cable 1RM`() = + runTest { + exerciseRepository.updateOneRepMax("bench-press", 40f) + val failure = IllegalStateException("test failure") + val failingRepository = repositoryWithExerciseUpdate( + afterDelegateUpdate = { throw failure }, + ) + + val thrown = assertFailsWith { + failingRepository.saveSessionForTest(profileId = "athlete-a") + } + + assertEquals(failure::class, thrown::class) + assertEquals(failure.message, thrown.message) + assertEquals(emptyList(), workoutRepository.getAllSessions("athlete-a").first()) + assertNull(repository.getLatestAssessment("bench-press", "athlete-a")) + assertEquals( + 40f, + exerciseRepository.getExerciseById("bench-press")?.oneRepMaxKg, + ) + } + + @Test + fun `pre-exercise-write failure does not restore over a newer value`() = runTest { + exerciseRepository.updateOneRepMax("bench-press", 40f) + val failingWorkoutRepository = object : WorkoutRepository by workoutRepository { + override suspend fun saveSession(session: WorkoutSession) { + exerciseRepository.updateOneRepMax("bench-press", 55f) + throw IllegalStateException("pre-write failure") + } + } + val failingRepository = SqlDelightAssessmentRepository( + database, + failingWorkoutRepository, + exerciseRepository, + ) + + assertFailsWith { + failingRepository.saveSessionForTest(profileId = "athlete-a") + } + + assertEquals( + 55f, + exerciseRepository.getExerciseById("bench-press")?.oneRepMaxKg, ) + assertNull(repository.getLatestAssessment("bench-press", "athlete-a")) + } - val exercise = exerciseRepository.getExerciseById("bench-press") - assertEquals(60f, exercise?.oneRepMaxKg, "Total 120kg override must persist as 60kg per cable") + @Test + fun `compensation snapshots the value immediately before the exercise write`() = runTest { + exerciseRepository.updateOneRepMax("bench-press", 40f) + val workoutWithConcurrentManualUpdate = object : WorkoutRepository by workoutRepository { + override suspend fun saveSession(session: WorkoutSession) { + workoutRepository.saveSession(session) + exerciseRepository.updateOneRepMax("bench-press", 55f) + } + } + val failingExerciseRepository = object : ExerciseRepository by exerciseRepository { + override suspend fun updateOneRepMax( + exerciseId: String, + oneRepMaxKg: Float?, + ) { + exerciseRepository.updateOneRepMax(exerciseId, oneRepMaxKg) + throw IllegalStateException("post-write failure") + } + } + val failingRepository = SqlDelightAssessmentRepository( + database, + workoutWithConcurrentManualUpdate, + failingExerciseRepository, + ) + + assertFailsWith { + failingRepository.saveSessionForTest(profileId = "athlete-a") + } + + assertEquals(emptyList(), workoutRepository.getAllSessions("athlete-a").first()) + assertNull(repository.getLatestAssessment("bench-press", "athlete-a")) + assertEquals( + 55f, + exerciseRepository.getExerciseById("bench-press")?.oneRepMaxKg, + ) } + @Test + fun `compare-and-set compensation preserves a newer post-write value`() = runTest { + exerciseRepository.updateOneRepMax("bench-press", 40f) + val failingRepository = repositoryWithExerciseUpdate { + exerciseRepository.updateOneRepMax("bench-press", 55f) + throw IllegalStateException("failure after newer value") + } + + assertFailsWith { + failingRepository.saveSessionForTest(profileId = "athlete-a") + } + + assertEquals(emptyList(), workoutRepository.getAllSessions("athlete-a").first()) + assertNull(repository.getLatestAssessment("bench-press", "athlete-a")) + assertEquals( + 55f, + exerciseRepository.getExerciseById("bench-press")?.oneRepMaxKg, + ) + } + + @Test + fun `restore precedes suspendable cleanup so a same-value newer write survives`() = runTest { + exerciseRepository.updateOneRepMax("bench-press", 40f) + val deleteSessionReached = CompletableDeferred() + val releaseDeleteSession = CompletableDeferred() + val pausingWorkoutRepository = object : WorkoutRepository by workoutRepository { + override suspend fun deleteSession(sessionId: String) { + deleteSessionReached.complete(Unit) + releaseDeleteSession.await() + workoutRepository.deleteSession(sessionId) + } + } + val failingExerciseRepository = object : ExerciseRepository by exerciseRepository { + override suspend fun updateOneRepMax( + exerciseId: String, + oneRepMaxKg: Float?, + ) { + exerciseRepository.updateOneRepMax(exerciseId, oneRepMaxKg) + throw IllegalStateException("post-write failure") + } + } + val failingRepository = SqlDelightAssessmentRepository( + database, + pausingWorkoutRepository, + failingExerciseRepository, + ) + val save = async { + runCatching { + failingRepository.saveSessionForTest( + estimatedOneRepMaxKg = 100f, + profileId = "athlete-a", + ) + } + } + + deleteSessionReached.await() + exerciseRepository.updateOneRepMax("bench-press", 50f) + releaseDeleteSession.complete(Unit) + val failure = save.await().exceptionOrNull() + + assertEquals(IllegalStateException::class, failure?.let { it::class }) + assertEquals("post-write failure", failure?.message) + assertEquals(emptyList(), workoutRepository.getAllSessions("athlete-a").first()) + assertNull(repository.getLatestAssessment("bench-press", "athlete-a")) + assertEquals( + 50f, + exerciseRepository.getExerciseById("bench-press")?.oneRepMaxKg, + ) + } + + @Test + fun `concurrent failing and successful saves are serialized and keep the successful row`() = + runTest { + exerciseRepository.updateOneRepMax("bench-press", 40f) + val ioDispatcher = StandardTestDispatcher(testScheduler) + val updateCount = AtomicInteger(0) + val firstUpdateReached = CompletableDeferred() + val releaseFirstFailure = CompletableDeferred() + val secondUpdateReached = CompletableDeferred() + val serializingExerciseRepository = object : ExerciseRepository by exerciseRepository { + override suspend fun updateOneRepMax( + exerciseId: String, + oneRepMaxKg: Float?, + ) { + exerciseRepository.updateOneRepMax(exerciseId, oneRepMaxKg) + when (updateCount.incrementAndGet()) { + 1 -> { + firstUpdateReached.complete(Unit) + releaseFirstFailure.await() + throw IllegalStateException("first save fails") + } + 2 -> secondUpdateReached.complete(Unit) + } + } + } + val serializingRepository = SqlDelightAssessmentRepository( + database, + workoutRepository, + serializingExerciseRepository, + ioDispatcher, + ) + val first = async { + runCatching { + serializingRepository.saveSessionForTest( + estimatedOneRepMaxKg = 100f, + profileId = "athlete-a", + ) + } + } + runCurrent() + firstUpdateReached.await() + val second = async { + serializingRepository.saveSessionForTest( + estimatedOneRepMaxKg = 120f, + profileId = "athlete-a", + ) + } + runCurrent() + + assertFalse(secondUpdateReached.isCompleted) + + releaseFirstFailure.complete(Unit) + runCurrent() + val firstFailure = first.await().exceptionOrNull() + assertEquals(IllegalStateException::class, firstFailure?.let { it::class }) + assertEquals("first save fails", firstFailure?.message) + val successfulSessionId = second.await() + + assertEquals( + listOf(successfulSessionId), + workoutRepository.getAllSessions("athlete-a").first().map { it.id }, + ) + val assessment = repository.getLatestAssessment("bench-press", "athlete-a") + assertEquals(successfulSessionId, assessment?.assessmentSessionId) + assertEquals(120f, assessment?.estimatedOneRepMaxKg) + assertEquals( + 60f, + exerciseRepository.getExerciseById("bench-press")?.oneRepMaxKg, + ) + } + + @Test + fun `raw save cannot interleave with compensating session insert identity`() = runTest { + val ioDispatcher = StandardTestDispatcher(testScheduler) + val firstUpdateReached = CompletableDeferred() + val releaseFirstFailure = CompletableDeferred() + val failingRepository = repositoryWithExerciseUpdate(ioDispatcher) { + firstUpdateReached.complete(Unit) + releaseFirstFailure.await() + throw IllegalStateException("session save fails") + } + val failingSession = async { + runCatching { + failingRepository.saveSessionForTest(profileId = "athlete-a") + } + } + runCurrent() + firstUpdateReached.await() + val rawSave = async { + failingRepository.saveAssessment( + exerciseId = "bench-press", + estimatedOneRepMaxKg = 135f, + loadVelocityDataJson = "[]", + sessionId = null, + userOverrideKg = null, + profileId = "athlete-a", + ) + } + runCurrent() + + assertFalse(rawSave.isCompleted) + + releaseFirstFailure.complete(Unit) + runCurrent() + val sessionFailure = failingSession.await().exceptionOrNull() + assertEquals(IllegalStateException::class, sessionFailure?.let { it::class }) + assertEquals("session save fails", sessionFailure?.message) + val rawId = rawSave.await() + val remaining = repository.getAssessmentsByExercise("bench-press", "athlete-a").first() + assertEquals(listOf(rawId), remaining.map { it.id }) + assertEquals(135f, remaining.single().estimatedOneRepMaxKg) + assertNull(remaining.single().assessmentSessionId) + } + + @Test + fun `sixteen concurrent raw saves return IDs for their own exercise rows`() = runTest { + val calls = (0 until 16).map { index -> + val exerciseId = "raw-exercise-$index" + insertExercise(id = exerciseId, name = "Raw Exercise $index") + Triple(exerciseId, "athlete-a", 100f + index) + } + + val saves = calls.map { (exerciseId, profileId, estimateKg) -> + async(Dispatchers.Default) { + val returnedId = repository.saveAssessment( + exerciseId = exerciseId, + estimatedOneRepMaxKg = estimateKg, + loadVelocityDataJson = "[]", + sessionId = null, + userOverrideKg = null, + profileId = profileId, + ) + Triple(exerciseId, estimateKg, returnedId) + } + }.awaitAll() + + assertEquals(16, saves.map { it.third }.distinct().size) + saves.forEach { (exerciseId, estimateKg, returnedId) -> + val row = repository.getLatestAssessment(exerciseId, "athlete-a") + assertEquals(returnedId, row?.id, exerciseId) + assertEquals(estimateKg, row?.estimatedOneRepMaxKg, exerciseId) + } + } + + @Test + fun `real child cancellation runs non-cancellable compensation and escapes unchanged`() = + runTest { + exerciseRepository.updateOneRepMax("bench-press", 40f) + val exerciseWriteApplied = CompletableDeferred() + val cancellingRepository = repositoryWithExerciseUpdate { + exerciseWriteApplied.complete(Unit) + awaitCancellation() + } + val save = async { + cancellingRepository.saveSessionForTest( + estimatedOneRepMaxKg = 120f, + profileId = "athlete-a", + ) + } + exerciseWriteApplied.await() + val cancellation = CancellationException("route popped") + + save.cancel(cancellation) + val thrown = assertFailsWith { save.await() } + + assertEquals(CancellationException::class, thrown::class) + assertEquals(cancellation.message, thrown.message) + assertEquals(emptyList(), workoutRepository.getAllSessions("athlete-a").first()) + assertNull(repository.getLatestAssessment("bench-press", "athlete-a")) + assertEquals( + 40f, + exerciseRepository.getExerciseById("bench-press")?.oneRepMaxKg, + ) + } + + private fun repositoryWithExerciseUpdate( + ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + afterDelegateUpdate: suspend () -> Unit, + ): SqlDelightAssessmentRepository { + val failingExerciseRepository = object : ExerciseRepository by exerciseRepository { + override suspend fun updateOneRepMax( + exerciseId: String, + oneRepMaxKg: Float?, + ) { + exerciseRepository.updateOneRepMax(exerciseId, oneRepMaxKg) + afterDelegateUpdate() + } + } + return SqlDelightAssessmentRepository( + database, + workoutRepository, + failingExerciseRepository, + ioDispatcher, + ) + } + + private suspend fun SqlDelightAssessmentRepository.saveSessionForTest( + estimatedOneRepMaxKg: Float = 100f, + userOverrideKg: Float? = null, + profileId: String, + ): String = saveAssessmentSession( + exerciseId = "bench-press", + exerciseName = "Bench Press", + estimatedOneRepMaxKg = estimatedOneRepMaxKg, + loadVelocityDataJson = "[]", + userOverrideKg = userOverrideKg, + totalReps = 9, + durationMs = 60_000L, + weightPerCableKg = 30f, + profileId = profileId, + ) + private fun insertExercise(id: String, name: String) { database.vitruvianDatabaseQueries.insertExercise( id = id, diff --git a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightProfilePreferencesRepositoryTest.kt b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightProfilePreferencesRepositoryTest.kt new file mode 100644 index 000000000..93053d90e --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightProfilePreferencesRepositoryTest.kt @@ -0,0 +1,335 @@ +package com.devil.phoenixproject.data.repository + +import com.devil.phoenixproject.data.preferences.ProfilePreferencesCodec +import com.devil.phoenixproject.data.preferences.SettingsProfileLocalSafetyStore +import com.devil.phoenixproject.database.VitruvianDatabase +import com.devil.phoenixproject.domain.model.CoreProfilePreferences +import com.devil.phoenixproject.domain.model.LedPreferences +import com.devil.phoenixproject.domain.model.ProfileLocalSafetyPreferences +import com.devil.phoenixproject.domain.model.ProfilePreferenceSectionName +import com.devil.phoenixproject.domain.model.ProfilePreferenceValidity +import com.devil.phoenixproject.domain.model.RackItem +import com.devil.phoenixproject.domain.model.RackPreferences +import com.devil.phoenixproject.domain.model.VbtPreferences +import com.devil.phoenixproject.domain.model.WeightUnit +import com.devil.phoenixproject.domain.model.WorkoutPreferences +import com.devil.phoenixproject.testutil.createTestDatabase +import com.russhwolf.settings.MapSettings +import com.russhwolf.settings.Settings +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.flow.dropWhile +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +class SqlDelightProfilePreferencesRepositoryTest { + private lateinit var database: VitruvianDatabase + private lateinit var repository: ProfilePreferencesRepository + + @Before + fun setup() { + database = createTestDatabase() + repository = SqlDelightProfilePreferencesRepository(database) + } + + @Test + fun editingCoreOnlyAdvancesCoreMetadata() = runTest { + insertProfile("a") + repository.insertDefaults("a") + val before = repository.get("a") + + repository.updateCore( + profileId = "a", + value = before.core.value.copy(bodyWeightKg = 80f), + now = 200, + ) + val after = repository.get("a") + + assertEquals(before.core.metadata.localGeneration + 1, after.core.metadata.localGeneration) + assertEquals(200, after.core.metadata.updatedAt) + assertTrue(after.core.metadata.dirty) + assertEquals(before.core.metadata.serverRevision, after.core.metadata.serverRevision) + assertEquals(before.rack, after.rack) + assertEquals(before.workout, after.workout) + assertEquals(before.led, after.led) + assertEquals(before.vbt, after.vbt) + } + + @Test + fun eachDocumentEditOnlyAdvancesItsOwnMetadata() = runTest { + insertProfile("a") + repository.insertDefaults("a") + + assertOnlySectionChanged(ProfilePreferenceSectionName.RACK, now = 201) { + repository.updateRack( + "a", + RackPreferences(items = listOf(RackItem(id = "plate", name = "Plate", weightKg = 10f))), + 201, + ) + } + assertOnlySectionChanged(ProfilePreferenceSectionName.WORKOUT, now = 202) { + repository.updateWorkout("a", WorkoutPreferences(stopAtTop = true), 202) + } + assertOnlySectionChanged(ProfilePreferenceSectionName.LED, now = 203) { + repository.updateLed("a", LedPreferences(colorScheme = 4, discoModeUnlocked = true), 203) + } + assertOnlySectionChanged(ProfilePreferenceSectionName.VBT, now = 204) { + repository.updateVbt( + "a", + VbtPreferences(enabled = false, velocityLossThresholdPercent = 30), + 204, + ) + } + } + + @Test + fun malformedWorkoutRawSurvivesRackEditAndOnlyValidWorkoutWritesReplaceIt() = runTest { + insertProfile("a") + repository.insertDefaults("a") + val malformed = " { definitely-not-json ] \n" + database.vitruvianDatabaseQueries.updateWorkoutProfilePreferences(malformed, 10, "a") + + val invalid = repository.get("a") + assertEquals(WorkoutPreferences(), invalid.workout.value) + assertEquals(malformed, invalid.workout.raw) + assertIs(invalid.workout.validity) + + repository.updateRack( + "a", + RackPreferences(items = listOf(RackItem(id = "bench", name = "Bench", weightKg = 12f))), + 20, + ) + + assertEquals( + malformed, + database.vitruvianDatabaseQueries.selectProfilePreferences("a") + .executeAsOne() + .workout_preferences_json, + ) + assertEquals(malformed, repository.get("a").workout.raw) + + repository.resetInvalidSection("a", ProfilePreferenceSectionName.WORKOUT, 30) + val reset = repository.get("a") + assertIs(reset.workout.validity) + assertEquals(WorkoutPreferences(), reset.workout.value) + assertNotEquals(malformed, reset.workout.raw) + assertEquals( + invalid.workout.metadata.localGeneration + 1, + reset.workout.metadata.localGeneration, + ) + assertEquals(invalid.workout.metadata.serverRevision, reset.workout.metadata.serverRevision) + + database.vitruvianDatabaseQueries.updateWorkoutProfilePreferences(malformed, 40, "a") + val edited = WorkoutPreferences(stopAtTop = true) + repository.updateWorkout("a", edited, 50) + assertEquals(edited, repository.get("a").workout.value) + assertEquals( + ProfilePreferencesCodec.encodeWorkout(edited), + database.vitruvianDatabaseQueries.selectProfilePreferences("a") + .executeAsOne() + .workout_preferences_json, + ) + } + + @Test + fun invalidTypedUpdatesAreRejectedWithoutChangingStoredValues() = runTest { + insertProfile("a") + repository.insertDefaults("a") + val before = repository.get("a") + + assertFailsWith { + repository.updateCore( + "a", + CoreProfilePreferences( + bodyWeightKg = 500f, + weightUnit = WeightUnit.KG, + weightIncrement = 2.5f, + ), + 200, + ) + } + assertFailsWith { + repository.updateWorkout( + "a", + WorkoutPreferences(autoStartCountdownSeconds = 99), + 200, + ) + } + + assertEquals(before, repository.get("a")) + } + + @Test + fun observeMapsSubsequentDatabaseUpdates() = runTest { + insertProfile("a") + repository.insertDefaults("a") + val initialGeneration = repository.get("a").core.metadata.localGeneration + + repository.updateCore("a", CoreProfilePreferences(bodyWeightKg = 80f), 222) + + val observed = repository.observe("a") + .dropWhile { it.core.metadata.localGeneration == initialGeneration } + .first() + assertEquals(80f, observed.core.value.bodyWeightKg) + assertEquals(222, observed.core.metadata.updatedAt) + } + + @Test + fun localSafetyKeysAreProfilePrefixedAndLegacyCopyUsesImmutableSnapshot() = runTest { + val settings = MapSettings() + val store = SettingsProfileLocalSafetyStore(settings) + val a = ProfileLocalSafetyPreferences("red", true, true, false) + val b = ProfileLocalSafetyPreferences("blue", false, false, true) + + store.write("a", a) + store.write("b", b) + + assertEquals(a, store.read("a")) + assertEquals(b, store.read("b")) + assertEquals("red", settings.getStringOrNull("profile_a_safe_word")) + assertEquals("blue", settings.getStringOrNull("profile_b_safe_word")) + assertNull(settings.getStringOrNull("safe_word")) + + val legacySnapshot = ProfileLocalSafetyPreferences("copied", true, false, true) + store.copyLegacyToProfiles(listOf("a", "b"), legacySnapshot) + assertEquals(legacySnapshot, store.read("a")) + assertEquals(legacySnapshot, store.read("b")) + } + + @Test + fun partialLocalSafetyWriteRollsBackEveryProfileKey() { + val settings = OneShotFailingSettings() + val store = SettingsProfileLocalSafetyStore(settings) + val previous = ProfileLocalSafetyPreferences("original", true, true, false) + store.write("a", previous) + settings.failNextBooleanWrite = true + + assertFailsWith { + store.write("a", ProfileLocalSafetyPreferences("replacement", false, false, true)) + } + + assertEquals(previous, store.read("a")) + } + + @Test + fun deleteRemovesOnlyTheSelectedProfilesSafetyKeys() { + val settings = MapSettings() + val store = SettingsProfileLocalSafetyStore(settings) + val a = ProfileLocalSafetyPreferences("red", true, true, true) + val b = ProfileLocalSafetyPreferences("blue", true, true, true) + store.write("a", a) + store.write("b", b) + + store.delete("a") + + assertEquals(ProfileLocalSafetyPreferences(), store.read("a")) + assertEquals(b, store.read("b")) + } + + private fun insertProfile(id: String) { + database.vitruvianDatabaseQueries.insertProfile(id, id, 0, 1, 0) + } + + private suspend fun assertOnlySectionChanged( + section: ProfilePreferenceSectionName, + now: Long, + update: suspend () -> Unit, + ) { + val before = repository.get("a") + update() + val after = repository.get("a") + + fun assertMetadataChange( + expectedSection: ProfilePreferenceSectionName, + beforeGeneration: Long, + beforeRevision: Long, + afterGeneration: Long, + afterRevision: Long, + afterUpdatedAt: Long, + afterDirty: Boolean, + ) { + if (section == expectedSection) { + assertEquals(beforeGeneration + 1, afterGeneration) + assertEquals(now, afterUpdatedAt) + assertTrue(afterDirty) + } else { + assertEquals(beforeGeneration, afterGeneration) + } + assertEquals(beforeRevision, afterRevision) + } + + assertMetadataChange( + ProfilePreferenceSectionName.CORE, + before.core.metadata.localGeneration, + before.core.metadata.serverRevision, + after.core.metadata.localGeneration, + after.core.metadata.serverRevision, + after.core.metadata.updatedAt, + after.core.metadata.dirty, + ) + assertMetadataChange( + ProfilePreferenceSectionName.RACK, + before.rack.metadata.localGeneration, + before.rack.metadata.serverRevision, + after.rack.metadata.localGeneration, + after.rack.metadata.serverRevision, + after.rack.metadata.updatedAt, + after.rack.metadata.dirty, + ) + assertMetadataChange( + ProfilePreferenceSectionName.WORKOUT, + before.workout.metadata.localGeneration, + before.workout.metadata.serverRevision, + after.workout.metadata.localGeneration, + after.workout.metadata.serverRevision, + after.workout.metadata.updatedAt, + after.workout.metadata.dirty, + ) + assertMetadataChange( + ProfilePreferenceSectionName.LED, + before.led.metadata.localGeneration, + before.led.metadata.serverRevision, + after.led.metadata.localGeneration, + after.led.metadata.serverRevision, + after.led.metadata.updatedAt, + after.led.metadata.dirty, + ) + assertMetadataChange( + ProfilePreferenceSectionName.VBT, + before.vbt.metadata.localGeneration, + before.vbt.metadata.serverRevision, + after.vbt.metadata.localGeneration, + after.vbt.metadata.serverRevision, + after.vbt.metadata.updatedAt, + after.vbt.metadata.dirty, + ) + + if (section != ProfilePreferenceSectionName.CORE) assertEquals(before.core, after.core) + if (section != ProfilePreferenceSectionName.RACK) assertEquals(before.rack, after.rack) + if (section != ProfilePreferenceSectionName.WORKOUT) assertEquals(before.workout, after.workout) + if (section != ProfilePreferenceSectionName.LED) assertEquals(before.led, after.led) + if (section != ProfilePreferenceSectionName.VBT) assertEquals(before.vbt, after.vbt) + } + + private class InjectedSettingsFailure : IllegalStateException("injected Settings failure") + + private class OneShotFailingSettings( + private val delegate: Settings = MapSettings(), + ) : Settings by delegate { + var failNextBooleanWrite = false + + override fun putBoolean(key: String, value: Boolean) { + if (failNextBooleanWrite) { + failNextBooleanWrite = false + throw InjectedSettingsFailure() + } + delegate.putBoolean(key, value) + } + } +} diff --git a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightUserProfileRepositoryTest.kt b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightUserProfileRepositoryTest.kt index 385a3a7bc..af21596c0 100644 --- a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightUserProfileRepositoryTest.kt +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightUserProfileRepositoryTest.kt @@ -1,89 +1,1256 @@ package com.devil.phoenixproject.data.repository +import app.cash.sqldelight.db.QueryResult +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.db.SqlPreparedStatement +import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver +import com.devil.phoenixproject.data.preferences.ProfileLocalSafetyStore +import com.devil.phoenixproject.data.preferences.SettingsProfileLocalSafetyStore import com.devil.phoenixproject.database.VitruvianDatabase -import com.devil.phoenixproject.testutil.createTestDatabase +import com.devil.phoenixproject.domain.model.CoreProfilePreferences +import com.devil.phoenixproject.domain.model.LedPreferences +import com.devil.phoenixproject.domain.model.ProfileLocalSafetyPreferences +import com.devil.phoenixproject.domain.model.RackItem +import com.devil.phoenixproject.domain.model.RackPreferences +import com.devil.phoenixproject.domain.model.UserProfilePreferences +import com.devil.phoenixproject.domain.model.VbtPreferences +import com.devil.phoenixproject.domain.model.WorkoutPreferences +import com.devil.phoenixproject.testutil.FakeUserProfileRepository +import com.russhwolf.settings.MapSettings +import com.russhwolf.settings.Settings import kotlin.test.assertEquals +import kotlin.test.assertFails +import kotlin.test.assertFailsWith import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotEquals import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame import kotlin.test.assertTrue +import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +@OptIn(ExperimentalCoroutinesApi::class) class SqlDelightUserProfileRepositoryTest { - + private lateinit var sqlDriver: SqlDriver private lateinit var database: VitruvianDatabase + private lateinit var preferenceStore: FaultingProfilePreferencesRepository + private lateinit var settings: MapSettings + private lateinit var safetyStore: FaultingProfileLocalSafetyStore private lateinit var repository: SqlDelightUserProfileRepository @Before fun setup() { - database = createTestDatabase() - repository = SqlDelightUserProfileRepository(database) + sqlDriver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) + database = createDatabase(sqlDriver) + preferenceStore = FaultingProfilePreferencesRepository( + SqlDelightProfilePreferencesRepository(database), + ) + settings = MapSettings() + safetyStore = FaultingProfileLocalSafetyStore( + SettingsProfileLocalSafetyStore(settings), + ) + repository = createRepository(database, preferenceStore, safetyStore) } @Test - fun `default profile exists on init`() = runTest { - repository.refreshProfiles() + fun constructorKeepsContextSwitchingUntilExplicitReconciliation() = runTest { + assertEquals("default", repository.activeProfile.value?.id) + assertNull( + database.vitruvianDatabaseQueries.selectProfilePreferences("default") + .executeAsOneOrNull(), + ) + val switching = assertIs(repository.activeProfileContext.value) + assertEquals("default", switching.targetProfileId) - val active = repository.activeProfile.value - val all = repository.allProfiles.value + preferenceStore.seedMissingProfiles() + repository.reconcileActiveProfileContext() - assertEquals("default", active?.id) - assertEquals(1, all.size) + val ready = assertIs(repository.activeProfileContext.value) + assertEquals("default", ready.profile.id) + assertEquals("default", ready.preferences.profileId) + assertEquals(ProfileLocalSafetyPreferences(), ready.localSafety) } @Test - fun `createProfile and setActiveProfile updates active`() = runTest { + fun deletionPolicyCoversEveryDirectProfileOwnedSqliteTable() { + val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) + createDatabase(driver) + val directProfileTables = mutableSetOf() + val tableNames = mutableListOf() + driver.executeQuery( + identifier = null, + sql = "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'", + mapper = { cursor -> + while (cursor.next().value) cursor.getString(0)?.let(tableNames::add) + QueryResult.Value(Unit) + }, + parameters = 0, + ) + tableNames.forEach { table -> + driver.executeQuery( + identifier = null, + sql = "PRAGMA table_info(\"$table\")", + mapper = { cursor -> + while (cursor.next().value) { + if (cursor.getString(1) in setOf("profile_id", "profileId")) { + directProfileTables += table + } + } + QueryResult.Value(Unit) + }, + parameters = 0, + ) + } + + assertEquals( + directProfileTables, + ProfileDeletionMergePolicy.directProfileOwnedTables, + "Every table with an exact profile_id/profileId column needs an explicit deletion policy", + ) + assertTrue("ExerciseMvt" in directProfileTables) + assertTrue("RoutineGroup" in directProfileTables) + assertTrue("VelocityOneRepMaxEstimate" in directProfileTables) + assertTrue("PendingProfileLocalCleanup" in directProfileTables) + } + + @Test + fun createProfileAtomicallyAddsIdentityAndDefaultsWithoutActivatingIt() = runTest { + ready() + val created = repository.createProfile("Alex", 2) - repository.setActiveProfile(created.id) - assertEquals(created.id, repository.activeProfile.value?.id) - assertTrue(repository.allProfiles.value.any { it.id == created.id }) + assertFalse(created.isActive) + assertEquals("default", repository.activeProfile.value?.id) + assertNotNull(database.vitruvianDatabaseQueries.getProfileById(created.id).executeAsOneOrNull()) + assertNotNull( + database.vitruvianDatabaseQueries.selectProfilePreferences(created.id) + .executeAsOneOrNull(), + ) + assertEquals( + 1L, + database.vitruvianDatabaseQueries.selectProfilePreferences(created.id) + .executeAsOne() + .legacy_migration_version, + ) + assertEquals("default", assertIs(repository.activeProfileContext.value).profile.id) } @Test - fun `deleteProfile prevents deleting default and resets active`() = runTest { - val created = repository.createProfile("Jordan", 1) - repository.setActiveProfile(created.id) + fun seededExistingProfilesRemainEligibleForLegacyMigration() = runTest { + assertNull( + database.vitruvianDatabaseQueries.selectProfilePreferences("default") + .executeAsOneOrNull(), + ) + + preferenceStore.seedMissingProfiles() + + assertEquals( + 0L, + database.vitruvianDatabaseQueries.selectProfilePreferences("default") + .executeAsOne() + .legacy_migration_version, + ) + } + + @Test + fun createAndActivateProfilePublishesMatchingCompleteContext() = runTest { + ready() + + val created = repository.createAndActivateProfile(" Alex ", 2) + + assertEquals("Alex", created.name) + assertTrue(created.isActive) + assertEquals(created.id, database.vitruvianDatabaseQueries.getActiveProfile().executeAsOne().id) + val context = assertIs(repository.activeProfileContext.value) + assertEquals(created.id, context.profile.id) + assertEquals(created.id, context.preferences.profileId) + assertEquals(safetyStore.read(created.id), context.localSafety) + assertNull(database.vitruvianDatabaseQueries.selectPendingProfileContextRecovery().executeAsOneOrNull()) + } + + @Test + fun switchingNeverEmitsMixedProfileContext() = runTest { + ready() + val profileA = repository.createAndActivateProfile("A", 1) + val profileB = repository.createAndActivateProfile("B", 2) + val observed = mutableListOf() + val job = launch(UnconfinedTestDispatcher(testScheduler)) { + repository.activeProfileContext.drop(1).take(2).toList(observed) + } + + repository.setActiveProfile(profileA.id) + + val switching = assertIs(observed[0]) + assertEquals(profileA.id, switching.targetProfileId) + val ready = assertIs(observed[1]) + assertEquals(profileA.id, ready.profile.id) + assertEquals(profileA.id, ready.preferences.profileId) + assertNotEquals(profileB.id, ready.profile.id) + job.cancel() + } + + @Test + fun profilesKeepIndependentValuesAcrossEverySectionAndLocalSafety() = runTest { + ready() + val profileA = repository.createAndActivateProfile("A", 1) + val rackA = RackPreferences(items = listOf(RackItem(id = "a", name = "A", weightKg = 5f))) + val workoutA = WorkoutPreferences(stopAtTop = true) + val ledA = LedPreferences(colorScheme = 2, discoModeUnlocked = true) + val vbtA = VbtPreferences(enabled = false, velocityLossThresholdPercent = 30) + val safetyA = ProfileLocalSafetyPreferences("red", true, true, false) + repository.updateCore(profileA.id, CoreProfilePreferences(bodyWeightKg = 80f)) + repository.updateRack(profileA.id, rackA) + repository.updateWorkout(profileA.id, workoutA) + repository.updateLed(profileA.id, ledA) + repository.updateVbt(profileA.id, vbtA) + repository.updateLocalSafety(profileA.id, safetyA) + + val profileB = repository.createAndActivateProfile("B", 2) + val rackB = RackPreferences(items = listOf(RackItem(id = "b", name = "B", weightKg = 10f))) + val workoutB = WorkoutPreferences(beepsEnabled = false) + val ledB = LedPreferences(colorScheme = 7) + val vbtB = VbtPreferences(enabled = true, velocityLossThresholdPercent = 40) + val safetyB = ProfileLocalSafetyPreferences("blue", false, false, true) + repository.updateCore(profileB.id, CoreProfilePreferences(bodyWeightKg = 100f)) + repository.updateRack(profileB.id, rackB) + repository.updateWorkout(profileB.id, workoutB) + repository.updateLed(profileB.id, ledB) + repository.updateVbt(profileB.id, vbtB) + repository.updateLocalSafety(profileB.id, safetyB) + + repository.setActiveProfile(profileA.id) + val readyA = assertIs(repository.activeProfileContext.value) + assertEquals(80f, readyA.preferences.core.value.bodyWeightKg) + assertEquals(rackA, readyA.preferences.rack.value) + assertEquals(workoutA, readyA.preferences.workout.value) + assertEquals(ledA, readyA.preferences.led.value) + assertEquals(vbtA, readyA.preferences.vbt.value) + assertEquals(safetyA, readyA.localSafety) + + repository.setActiveProfile(profileB.id) + val readyB = assertIs(repository.activeProfileContext.value) + assertEquals(100f, readyB.preferences.core.value.bodyWeightKg) + assertEquals(rackB, readyB.preferences.rack.value) + assertEquals(workoutB, readyB.preferences.workout.value) + assertEquals(ledB, readyB.preferences.led.value) + assertEquals(vbtB, readyB.preferences.vbt.value) + assertEquals(safetyB, readyB.localSafety) + } + + @Test + fun staleAndSwitchingMutationsAreRejectedWithoutTouchingPreferences() = runTest { + ready() + val profileA = repository.createAndActivateProfile("A", 1) + val profileB = repository.createAndActivateProfile("B", 2) + val aBefore = preferenceStore.get(profileA.id) + + assertFailsWith { + repository.updateCore(profileA.id, CoreProfilePreferences(bodyWeightKg = 80f)) + } + assertEquals(aBefore, preferenceStore.get(profileA.id)) + + val switchingRepository = createRepository( + database, + preferenceStore, + safetyStore, + ) + assertFailsWith { + switchingRepository.updateCore(profileB.id, CoreProfilePreferences(bodyWeightKg = 90f)) + } + } + + @Test + fun failedTypedUpdateLeavesReadyContextOnTheSameProfile() = runTest { + ready() + val activeId = repository.activeProfile.value!!.id + val before = assertIs(repository.activeProfileContext.value) + + assertFailsWith { + repository.updateWorkout( + activeId, + WorkoutPreferences(autoStartCountdownSeconds = 99), + ) + } + + assertEquals(before, repository.activeProfileContext.value) + } + + @Test + fun failedCreateAfterActivationRestoresPreviousDatabaseAndReadyContext() = runTest { + ready() + val previous = assertIs(repository.activeProfileContext.value) + val profileIdsBefore = repository.allProfiles.value.map { it.id }.toSet() + val preferenceIdsBefore = preferenceIds() + preferenceStore.failNextGet = true + + assertFails { repository.createAndActivateProfile("Cannot publish", 2) } + + assertEquals(previous.profile.id, database.vitruvianDatabaseQueries.getActiveProfile().executeAsOne().id) + assertEquals(previous, repository.activeProfileContext.value) + assertEquals(profileIdsBefore, repository.allProfiles.value.map { it.id }.toSet()) + assertEquals(preferenceIdsBefore, preferenceIds()) + assertNull(database.vitruvianDatabaseQueries.selectPendingProfileContextRecovery().executeAsOneOrNull()) + } + + @Test + fun failedSwitchAfterDatabaseActivationRestoresPreviousDatabaseAndReadyContext() = runTest { + ready() + val target = repository.createProfile("Target", 2) + val previous = assertIs(repository.activeProfileContext.value) + preferenceStore.failNextGetFor = target.id + + assertFails { repository.setActiveProfile(target.id) } + + assertEquals(previous.profile.id, database.vitruvianDatabaseQueries.getActiveProfile().executeAsOne().id) + assertEquals(previous, repository.activeProfileContext.value) + assertNull(database.vitruvianDatabaseQueries.selectPendingProfileContextRecovery().executeAsOneOrNull()) + } + + @Test + fun rollbackFailureStaysJournaledUntilReconciliationRestoresDatabaseAndReady() = runTest { + val fixture = createFaultingFixture() + fixture.preferenceStore.seedMissingProfiles() + fixture.repository.reconcileActiveProfileContext() + val target = fixture.repository.createProfile("Target", 2) + val previous = assertIs(fixture.repository.activeProfileContext.value) + fixture.preferenceStore.failNextGetFor = target.id + fixture.transitionFaults.failSetActiveAfterMatches = 1 + + assertFailsWith { + fixture.repository.setActiveProfile(target.id) + } + assertNotNull( + fixture.database.vitruvianDatabaseQueries.selectPendingProfileContextRecovery() + .executeAsOneOrNull(), + ) + + fixture.repository.reconcileActiveProfileContext() + + assertNull( + fixture.database.vitruvianDatabaseQueries.selectPendingProfileContextRecovery() + .executeAsOneOrNull(), + ) + assertEquals( + previous.profile.id, + fixture.database.vitruvianDatabaseQueries.getActiveProfile().executeAsOne().id, + ) + assertEquals( + previous.profile.id, + assertIs(fixture.repository.activeProfileContext.value).profile.id, + ) + } + + @Test + fun failedCreateIdentityDeleteRollsBackEarlierCompensationAndRetries() = runTest { + val fixture = createFaultingFixture() + fixture.preferenceStore.seedMissingProfiles() + fixture.repository.reconcileActiveProfileContext() + val profileIdsBefore = fixture.repository.allProfiles.value.map { it.id }.toSet() + val activeIdBefore = fixture.database.vitruvianDatabaseQueries.getActiveProfile().executeAsOne().id + fixture.preferenceStore.failNextGet = true + fixture.transitionFaults.failNextProfileDelete = true + + assertFailsWith { + fixture.repository.createAndActivateProfile("Failed create", 2) + } + val pending = fixture.database.vitruvianDatabaseQueries + .selectPendingProfileContextRecovery() + .executeAsOne() + val failedId = assertNotNull(pending.created_profile_id) + assertEquals( + failedId, + fixture.database.vitruvianDatabaseQueries.getActiveProfile().executeAsOne().id, + ) + assertNotNull( + fixture.database.vitruvianDatabaseQueries.getProfileById(failedId) + .executeAsOneOrNull(), + ) + assertNotNull( + fixture.database.vitruvianDatabaseQueries.selectProfilePreferences(failedId) + .executeAsOneOrNull(), + ) + + fixture.repository.reconcileActiveProfileContext() + + assertNull( + fixture.database.vitruvianDatabaseQueries.selectPendingProfileContextRecovery() + .executeAsOneOrNull(), + ) + assertNull(fixture.database.vitruvianDatabaseQueries.getProfileById(failedId).executeAsOneOrNull()) + assertNull( + fixture.database.vitruvianDatabaseQueries.selectProfilePreferences(failedId) + .executeAsOneOrNull(), + ) + assertEquals(profileIdsBefore, fixture.repository.allProfiles.value.map { it.id }.toSet()) + assertEquals( + activeIdBefore, + fixture.database.vitruvianDatabaseQueries.getActiveProfile().executeAsOne().id, + ) + } + + @Test + fun failedReadyPublicationKeepsRecoveryJournalUntilACompleteRetry() = runTest { + ready() + val target = repository.createProfile("Target", 2) + database.transaction { + database.vitruvianDatabaseQueries.enqueueProfileContextRecovery("default", null, 100) + database.vitruvianDatabaseQueries.setActiveProfile(target.id) + } + preferenceStore.failNextGetFor = "default" + + assertFailsWith { + repository.reconcileActiveProfileContext() + } + + assertEquals("default", database.vitruvianDatabaseQueries.getActiveProfile().executeAsOne().id) + assertNotNull( + database.vitruvianDatabaseQueries.selectPendingProfileContextRecovery() + .executeAsOneOrNull(), + ) + assertIs(repository.activeProfileContext.value) + + repository.reconcileActiveProfileContext() + + assertNull(database.vitruvianDatabaseQueries.selectPendingProfileContextRecovery().executeAsOneOrNull()) + assertEquals( + "default", + assertIs(repository.activeProfileContext.value).profile.id, + ) + } + + @Test + fun failedJournalClearRestoresSwitchingUntilRetryPublishesReadyAndDrainsJournal() = runTest { + val fixture = createFaultingFixture() + fixture.preferenceStore.seedMissingProfiles() + fixture.repository.reconcileActiveProfileContext() + val target = fixture.repository.createProfile("Target", 2) + fixture.database.transaction { + fixture.database.vitruvianDatabaseQueries.enqueueProfileContextRecovery( + "default", + null, + 100, + ) + fixture.database.vitruvianDatabaseQueries.setActiveProfile(target.id) + } + fixture.transitionFaults.failNextJournalClear = true + + assertFailsWith { + fixture.repository.reconcileActiveProfileContext() + } + + assertEquals( + "default", + fixture.database.vitruvianDatabaseQueries.getActiveProfile().executeAsOne().id, + ) + assertNotNull( + fixture.database.vitruvianDatabaseQueries.selectPendingProfileContextRecovery() + .executeAsOneOrNull(), + ) + val switching = assertIs( + fixture.repository.activeProfileContext.value, + ) + assertEquals("default", switching.targetProfileId) + + fixture.repository.reconcileActiveProfileContext() + + assertNull( + fixture.database.vitruvianDatabaseQueries.selectPendingProfileContextRecovery() + .executeAsOneOrNull(), + ) + val ready = assertIs( + fixture.repository.activeProfileContext.value, + ) + assertEquals("default", ready.profile.id) + assertEquals("default", ready.preferences.profileId) + } + + @Test + fun startupRecoveryDrainsJournalButRetainsSwitchingUntilOrdinaryReconciliation() = runTest { + ready() + val target = repository.createProfile("Target", 2) + database.transaction { + database.vitruvianDatabaseQueries.enqueueProfileContextRecovery("default", null, 100) + database.vitruvianDatabaseQueries.setActiveProfile(target.id) + } + + repository.recoverPendingProfileTransitionForStartup() + + assertNull(database.vitruvianDatabaseQueries.selectPendingProfileContextRecovery().executeAsOneOrNull()) + assertEquals("default", database.vitruvianDatabaseQueries.getActiveProfile().executeAsOne().id) + val switching = assertIs(repository.activeProfileContext.value) + assertEquals("default", switching.targetProfileId) + + repository.reconcileActiveProfileContext() + assertEquals("default", assertIs(repository.activeProfileContext.value).profile.id) + } + + @Test + fun pendingLocalCleanupDequeuesOnlyAfterEverySafetyKeyIsRemoved() = runTest { + val value = ProfileLocalSafetyPreferences("do-not-log", true, true, true) + safetyStore.write("source", value) + database.vitruvianDatabaseQueries.enqueueProfileLocalCleanup("source", 100) + safetyStore.failDeletes = true + + repository.retryPendingLocalCleanup() + assertEquals( + listOf("source"), + database.vitruvianDatabaseQueries.selectPendingProfileLocalCleanup() + .executeAsList() + .map { it.profile_id }, + ) + assertEquals("do-not-log", settings.getStringOrNull("profile_source_safe_word")) + + safetyStore.failDeletes = false + repository.retryPendingLocalCleanup() + assertTrue( + database.vitruvianDatabaseQueries.selectPendingProfileLocalCleanup() + .executeAsList() + .isEmpty(), + ) + assertNull(settings.getStringOrNull("profile_source_safe_word")) + } + + @Test + fun partialSettingsDeletionRemainsQueuedUntilRetryRemovesAllKeys() = runTest { + val partialSettings = OneShotRemoveFailingSettings(failAtRemove = 2) + val partialSafetyStore = SettingsProfileLocalSafetyStore(partialSettings) + val partialRepository = createRepository(database, preferenceStore, partialSafetyStore) + val value = ProfileLocalSafetyPreferences("partial", true, true, true) + partialSafetyStore.write("source", value) + database.vitruvianDatabaseQueries.enqueueProfileLocalCleanup("source", 100) + + partialRepository.retryPendingLocalCleanup() + + assertNotNull( + database.vitruvianDatabaseQueries.selectPendingProfileLocalCleanup() + .executeAsOneOrNull(), + ) + + partialRepository.retryPendingLocalCleanup() + + assertNull( + database.vitruvianDatabaseQueries.selectPendingProfileLocalCleanup() + .executeAsOneOrNull(), + ) + assertEquals(ProfileLocalSafetyPreferences(), partialSafetyStore.read("source")) + } + + @Test + fun deleteProfileRemovesPreferencesAndPublishesMatchingDefaultContext() = runTest { + ready() + val created = repository.createAndActivateProfile("Jordan", 1) val deleteDefault = repository.deleteProfile("default") val deleteCreated = repository.deleteProfile(created.id) assertFalse(deleteDefault) assertTrue(deleteCreated) - assertEquals("default", repository.activeProfile.value?.id) + assertEquals("default", database.vitruvianDatabaseQueries.getActiveProfile().executeAsOne().id) + assertEquals("default", assertIs(repository.activeProfileContext.value).profile.id) + assertNull( + database.vitruvianDatabaseQueries.selectProfilePreferences(created.id) + .executeAsOneOrNull(), + ) + } + + @Test + fun deleteActiveProfileRejectsAStaleExpectedIdWithoutMutation() = runTest { + ready() + val stale = repository.createAndActivateProfile("Stale", 1) + val current = repository.createAndActivateProfile("Current", 2) + + assertFailsWith { + repository.deleteActiveProfile(stale.id) + } + + assertNotNull(database.vitruvianDatabaseQueries.getProfileById(stale.id).executeAsOneOrNull()) + assertNotNull(database.vitruvianDatabaseQueries.getProfileById(current.id).executeAsOneOrNull()) + assertEquals( + current.id, + assertIs(repository.activeProfileContext.value).profile.id, + ) } @Test - fun `deleteProfile recomputes merged gamification stats without duplicate target rows`() = runTest { + fun deleteActiveProfileRacingAQueuedSwitchReassignsOnlyToDefault() = runTest { + val deletionEntered = CountDownLatch(1) + val releaseDeletion = CountDownLatch(1) + repository = createRepository( + database, + preferenceStore, + safetyStore, + beforeProfileDeletionCommit = { + deletionEntered.countDown() + check(releaseDeletion.await(5, TimeUnit.SECONDS)) + }, + ) + ready() + val source = repository.createAndActivateProfile("Source", 1) + val next = repository.createProfile("Next", 2) + insertWorkoutSession("atomic-delete-session", 5, 20.0, source.id) + + val deletion = async(Dispatchers.Default) { + repository.deleteActiveProfile(source.id) + } + assertTrue(deletionEntered.await(5, TimeUnit.SECONDS)) + val switch = async(Dispatchers.Default) { + repository.setActiveProfile(next.id) + } + releaseDeletion.countDown() + + assertTrue(deletion.await()) + switch.await() + + assertNull(database.vitruvianDatabaseQueries.getProfileById(source.id).executeAsOneOrNull()) + assertEquals( + "default", + database.vitruvianDatabaseQueries.selectSessionById("atomic-delete-session") + .executeAsOne().profile_id, + ) + assertEquals( + next.id, + assertIs(repository.activeProfileContext.value).profile.id, + ) + } + + @Test + fun activeDeletionPublishesExactTransitionThenCleansOnlySourceLocalSafety() = runTest { + ready() + val targetSafety = ProfileLocalSafetyPreferences("target-secret", false, true, false) + repository.updateLocalSafety("default", targetSafety) + val source = repository.createAndActivateProfile("Source", 1) + val sourceSafety = ProfileLocalSafetyPreferences("source-secret", true, true, true) + repository.updateLocalSafety(source.id, sourceSafety) + val observed = mutableListOf() + val job = launch(UnconfinedTestDispatcher(testScheduler)) { + repository.activeProfileContext.drop(1).take(2).toList(observed) + } + + assertTrue(repository.deleteProfile(source.id)) + + assertEquals( + listOf( + ActiveProfileContext.Switching("default"), + repository.activeProfileContext.value, + ), + observed, + ) + assertEquals("default", assertIs(observed[1]).profile.id) + assertEquals(ProfileLocalSafetyPreferences(), safetyStore.read(source.id)) + assertEquals(targetSafety, safetyStore.read("default")) + assertTrue(database.vitruvianDatabaseQueries.selectPendingProfileLocalCleanup().executeAsList().isEmpty()) + job.cancel() + } + + @Test + fun activeDeletionCancellationDuringPostCommitPublicationRecoversReadyAndPropagatesOriginal() = + runTest { + ready() + val source = repository.createAndActivateProfile("Source", 1) + val cancellation = CancellationException("cancel post-commit publication") + preferenceStore.cancelNextGetWith = cancellation + var propagatedByRepository: CancellationException? = null + + val deletion = async { + try { + repository.deleteActiveProfile(source.id) + } catch (error: CancellationException) { + propagatedByRepository = error + throw error + } + } + val thrown = assertFailsWith { deletion.await() } + + assertEquals(cancellation.message, thrown.message) + assertSame(cancellation, propagatedByRepository) + assertNull(database.vitruvianDatabaseQueries.getProfileById(source.id).executeAsOneOrNull()) + assertEquals( + "default", + database.vitruvianDatabaseQueries.getActiveProfile().executeAsOne().id, + ) + assertEquals( + "default", + assertIs(repository.activeProfileContext.value).profile.id, + ) + } + + @Test + fun activeDeletionCancellationPreservesRecoveryExceptionWhenReconciliationTrulyFails() = + runTest { + ready() + val source = repository.createAndActivateProfile("Source", 1) + val cancellation = CancellationException("cancel post-commit publication") + preferenceStore.cancelNextGetWith = cancellation + preferenceStore.failNextGet = true + var propagatedByRepository: Throwable? = null + + val thrown = supervisorScope { + val deletion = async { + try { + repository.deleteActiveProfile(source.id) + } catch (error: Throwable) { + propagatedByRepository = error + throw error + } + } + assertFailsWith { deletion.await() } + } + val recovery = assertIs(propagatedByRepository) + assertEquals(recovery.message, thrown.message) + assertSame(cancellation, recovery.cause) + assertEquals(1, cancellation.suppressed.size) + assertIs(cancellation.suppressed.single()) + assertNull(database.vitruvianDatabaseQueries.getProfileById(source.id).executeAsOneOrNull()) + assertEquals( + "default", + database.vitruvianDatabaseQueries.getActiveProfile().executeAsOne().id, + ) + assertEquals( + "default", + assertIs( + repository.activeProfileContext.value, + ).targetProfileId, + ) + } + + @Test + fun inactiveDeletionTargetsCurrentReadyProfileWithoutSwitching() = runTest { + ready() + val target = repository.createAndActivateProfile("Target", 1) + val source = repository.createProfile("Source", 2) + safetyStore.write(source.id, ProfileLocalSafetyPreferences("source-secret", true, true, true)) + val observed = mutableListOf() + val job = launch(UnconfinedTestDispatcher(testScheduler)) { + repository.activeProfileContext.drop(1).toList(observed) + } + + assertTrue(repository.deleteProfile(source.id)) + job.cancel() + + assertTrue(observed.none { it is ActiveProfileContext.Switching }) + assertEquals(target.id, assertIs(repository.activeProfileContext.value).profile.id) + assertEquals(ProfileLocalSafetyPreferences(), safetyStore.read(source.id)) + } + + @Test + fun beforeCommitFailureSeesJournalAndRollsBackSqlAndExactContext() = runTest { + var sawJournalInsideTransaction = false + repository = createRepository( + database, + preferenceStore, + safetyStore, + beforeProfileDeletionCommit = { + sawJournalInsideTransaction = database.vitruvianDatabaseQueries + .selectPendingProfileLocalCleanup() + .executeAsOneOrNull() != null + throw InjectedTransitionFailure() + }, + ) + ready() + val source = repository.createAndActivateProfile("Source", 1) + repository.updateCore(source.id, CoreProfilePreferences(bodyWeightKg = 83f)) + val sourceSafety = ProfileLocalSafetyPreferences("source-secret", true, true, true) + repository.updateLocalSafety(source.id, sourceSafety) + insertWorkoutSession("source-session", 5, 20.0, source.id) + val observed = mutableListOf() + val job = launch(UnconfinedTestDispatcher(testScheduler)) { + repository.activeProfileContext.drop(1).take(2).toList(observed) + } + + assertFailsWith { repository.deleteProfile(source.id) } + + assertTrue(sawJournalInsideTransaction) + assertEquals( + listOf( + ActiveProfileContext.Switching("default"), + observed.firstOrNull { it is ActiveProfileContext.Ready }, + ), + observed, + ) + assertEquals(source.id, assertIs(observed[1]).profile.id) + assertNotNull(database.vitruvianDatabaseQueries.getProfileById(source.id).executeAsOneOrNull()) + assertNotNull(database.vitruvianDatabaseQueries.selectProfilePreferences(source.id).executeAsOneOrNull()) + assertEquals(83f, preferenceStore.get(source.id).core.value.bodyWeightKg) + assertEquals(sourceSafety, safetyStore.read(source.id)) + assertEquals(source.id, database.vitruvianDatabaseQueries.getActiveProfile().executeAsOne().id) + assertEquals(1, countRows("WorkoutSession", "profile_id", source.id)) + assertTrue(database.vitruvianDatabaseQueries.selectPendingProfileLocalCleanup().executeAsList().isEmpty()) + job.cancel() + } + + @Test + fun externalConflictsKeepTargetParentsAndDeleteSourceChildrenWhileSourceOnlyGraphsMove() = runTest { + ready() + val source = repository.createProfile("Source", 1) + executeSql( + "INSERT INTO ExternalRoutine(id, externalId, provider, title, syncedAt, rawData, profileId) VALUES (?, ?, 'hevy', ?, 1, ?, ?)", + "target-routine", "shared", "Target", "target-bytes", "default", + ) + executeSql( + "INSERT INTO ExternalRoutine(id, externalId, provider, title, syncedAt, rawData, profileId) VALUES (?, ?, 'hevy', ?, 1, ?, ?)", + "source-conflict", "shared", "Source", "source-bytes", source.id, + ) + executeSql( + "INSERT INTO ExternalRoutineExercise(id, externalRoutineId, title) VALUES (?, ?, ?)", + "source-conflict-exercise", "source-conflict", "Delete me", + ) + executeSql( + "INSERT INTO ExternalRoutineSet(id, externalRoutineExerciseId, setIndex) VALUES (?, ?, 0)", + "source-conflict-set", "source-conflict-exercise", + ) + executeSql( + "INSERT INTO ExternalRoutine(id, externalId, provider, title, syncedAt, rawData, profileId) VALUES (?, ?, 'hevy', ?, 1, ?, ?)", + "source-only", "source-only", "Move", "source-only-bytes", source.id, + ) + executeSql( + "INSERT INTO ExternalRoutineExercise(id, externalRoutineId, title) VALUES (?, ?, ?)", + "source-only-exercise", "source-only", "Retain me", + ) + + assertTrue(repository.deleteProfile(source.id)) + + assertEquals("target-bytes", textValue("SELECT rawData FROM ExternalRoutine WHERE id = 'target-routine'")) + assertEquals(0, countRowsWhere("ExternalRoutine", "id = 'source-conflict'")) + assertEquals(0, countRowsWhere("ExternalRoutineExercise", "id = 'source-conflict-exercise'")) + assertEquals(0, countRowsWhere("ExternalRoutineSet", "id = 'source-conflict-set'")) + assertEquals("default", textValue("SELECT profileId FROM ExternalRoutine WHERE id = 'source-only'")) + assertEquals(1, countRowsWhere("ExternalRoutineExercise", "id = 'source-only-exercise'")) + } + + @Test + fun deletionExecutesEveryDirectProfileTablePolicy() = runTest { + ready() + val source = repository.createProfile("Source", 1) + val sourceId = source.id + insertWorkoutSession("owned-session", 5, 20.0, sourceId) + executeSql("INSERT INTO RoutineGroup(id, name, createdAt, profile_id) VALUES ('owned-group', 'G', 1, ?)", sourceId) + executeSql("INSERT INTO Routine(id, name, createdAt, profile_id, groupId) VALUES ('owned-routine', 'R', 1, ?, 'owned-group')", sourceId) + executeSql("INSERT INTO TrainingCycle(id, name, created_at, profile_id) VALUES ('owned-cycle', 'C', 1, ?)", sourceId) + executeSql("INSERT INTO AssessmentResult(exerciseId, estimatedOneRepMaxKg, loadVelocityData, createdAt, profile_id) VALUES ('bench', 100, '{}', 1, ?)", sourceId) + executeSql("INSERT INTO VelocityOneRepMaxEstimate(exerciseId, estimatedPerCableKg, mvtUsedMs, r2, distinctLoads, computedAt, profile_id) VALUES ('bench', 50, 200, .9, 3, 1, ?)", sourceId) + executeSql("INSERT INTO PersonalRecord(id, exerciseId, exerciseName, weight, reps, oneRepMax, achievedAt, workoutMode, prType, volume, phase, profile_id, uuid) VALUES (700, 'bench', 'Bench', 50, 5, 60, 1, 'OldSchool', 'MAX_WEIGHT', 250, 'COMBINED', ?, 'owned-pr')", sourceId) + executeSql("INSERT INTO EarnedBadge(id, badgeId, earnedAt, profile_id) VALUES (701, 'owned-badge', 1, ?)", sourceId) + executeSql("INSERT INTO StreakHistory(id, startDate, endDate, length, profile_id) VALUES (702, 1, 2, 2, ?)", sourceId) + executeSql("INSERT INTO ExerciseMvt(exerciseId, profile_id, personalMvtMs, sampleCount, updatedAt) VALUES ('bench', ?, 250, 2, 1)", sourceId) + executeSql("INSERT INTO ProgressionEvent(id, exercise_id, suggested_weight_kg, previous_weight_kg, reason, timestamp, profile_id) VALUES ('owned-progression', 'bench', 55, 50, 'test', 1, ?)", sourceId) + executeSql("INSERT INTO IntegrationStatus(provider, status, errorMessage, profileId) VALUES ('hevy', 'connected', 'source-state', ?)", sourceId) + executeSql("INSERT INTO IntegrationSyncCursor(provider, profileId, cursorType, cursorValue, updatedAt) VALUES ('hevy', ?, 'activities', 'source-cursor', 1)", sourceId) + executeSql("INSERT INTO IntegrationStatus(provider, status, errorMessage, profileId) VALUES ('hevy', 'connected', 'target-state', 'default')") + executeSql("INSERT INTO IntegrationSyncCursor(provider, profileId, cursorType, cursorValue, updatedAt) VALUES ('hevy', 'default', 'activities', 'target-cursor', 1)") + + assertTrue(repository.deleteProfile(sourceId)) + + listOf( + "WorkoutSession", + "RoutineGroup", + "Routine", + "TrainingCycle", + "AssessmentResult", + "VelocityOneRepMaxEstimate", + "PersonalRecord", + "EarnedBadge", + "StreakHistory", + "ExerciseMvt", + "ProgressionEvent", + ).forEach { table -> + assertEquals(0, countRows(table, "profile_id", sourceId), "$table retained source ownership") + assertTrue(countRows(table, "profile_id", "default") >= 1, "$table did not move to target") + } + assertEquals(0, countRows("IntegrationStatus", "profileId", sourceId)) + assertEquals(0, countRows("IntegrationSyncCursor", "profileId", sourceId)) + assertEquals("target-state", textValue("SELECT errorMessage FROM IntegrationStatus WHERE profileId = 'default'")) + assertEquals("target-cursor", textValue("SELECT cursorValue FROM IntegrationSyncCursor WHERE profileId = 'default'")) + assertNull(database.vitruvianDatabaseQueries.selectProfilePreferences(sourceId).executeAsOneOrNull()) + } + + @Test + fun personalRecordBadgeAndMvtCollisionsRetainTargetIdentityAndMergeMetadata() = runTest { + ready() + val source = repository.createProfile("Source", 1) + executeSql("INSERT INTO PersonalRecord(id, exerciseId, exerciseName, weight, reps, oneRepMax, achievedAt, workoutMode, prType, volume, phase, updatedAt, serverId, profile_id, uuid) VALUES (800, 'bench', 'Target Bench', 50, 5, 60, 10, 'Old School', 'MAX_WEIGHT', 250, 'COMBINED', 10, 'target-server', 'default', 'target-uuid')") + executeSql("INSERT INTO PersonalRecord(id, exerciseId, exerciseName, weight, reps, oneRepMax, achievedAt, workoutMode, prType, volume, phase, updatedAt, serverId, profile_id, uuid) VALUES (801, 'bench', '', 70, 5, 80, 20, 'OldSchool', 'MAX_WEIGHT', 350, 'COMBINED', 20, 'source-server', ?, 'source-uuid')", source.id) + executeSql("INSERT INTO EarnedBadge(id, badgeId, earnedAt, celebratedAt, updatedAt, serverId, profile_id) VALUES (810, 'shared', 200, 250, 20, 'target-badge', 'default')") + executeSql("INSERT INTO EarnedBadge(id, badgeId, earnedAt, celebratedAt, updatedAt, serverId, deletedAt, profile_id) VALUES (811, 'shared', 100, 300, 10, 'source-badge', 400, ?)", source.id) + executeSql("INSERT INTO ExerciseMvt(exerciseId, profile_id, personalMvtMs, sampleCount, updatedAt) VALUES ('bench', 'default', 200, 2, 20)") + executeSql("INSERT INTO ExerciseMvt(exerciseId, profile_id, personalMvtMs, sampleCount, updatedAt) VALUES ('bench', ?, 400, 6, 10)", source.id) + + assertTrue(repository.deleteProfile(source.id)) + + val pr = database.vitruvianDatabaseQueries.selectAllRecords("default").executeAsList().single { it.exerciseId == "bench" } + assertEquals(800, pr.id) + assertEquals(70.0, pr.weight) + assertEquals("Target Bench", pr.exerciseName) + assertEquals("Old School", pr.workoutMode) + assertEquals("source-server", pr.serverId) + assertEquals("source-uuid", pr.uuid) + val badge = database.vitruvianDatabaseQueries.selectAllEarnedBadges("default").executeAsList().single { it.badgeId == "shared" } + assertEquals(810, badge.id) + assertEquals(100, badge.earnedAt) + assertEquals(250, badge.celebratedAt) + assertEquals("target-badge", badge.serverId) + val mvt = database.vitruvianDatabaseQueries.selectExerciseMvt("bench", "default").executeAsOne() + assertEquals(350.0, mvt.personalMvtMs) + assertEquals(8, mvt.sampleCount) + } + + @Test + fun externalLeafAndProgramConflictsAreTargetWinsAndSourceOnlyRowsMove() = runTest { + ready() + val source = repository.createProfile("Source", 1) + val sourceId = source.id + seedExternalLeafPairs(sourceId) + + assertTrue(repository.deleteProfile(sourceId)) + + listOf( + "ExternalActivity" to "target-activity", + "ExternalRoutineFolder" to "target-folder", + "ExternalProgram" to "target-program", + "ExternalExerciseTemplate" to "target-template", + "ExternalExerciseTemplateMapping" to "target-mapping", + "ExternalBodyMeasurement" to "target-body", + ).forEach { (table, id) -> + assertEquals("target-bytes", textValue("SELECT rawData FROM $table WHERE id = '$id'"), table) + assertEquals(0, countRows(table, "profileId", sourceId), "$table retained source ownership") + } + assertEquals(0, countRowsWhere("ExternalProgramStats", "externalProgramId = 'source-program-conflict'")) + assertEquals(1, countRowsWhere("ExternalProgramStats", "externalProgramId = 'target-program'")) + listOf( + "ExternalActivity" to "source-activity-only", + "ExternalRoutineFolder" to "source-folder-only", + "ExternalProgram" to "source-program-only", + "ExternalExerciseTemplate" to "source-template-only", + "ExternalExerciseTemplateMapping" to "source-mapping-only", + "ExternalBodyMeasurement" to "source-body-only", + ).forEach { (table, id) -> + assertEquals("default", textValue("SELECT profileId FROM $table WHERE id = '$id'"), table) + } + assertEquals(1, countRowsWhere("ExternalProgramStats", "externalProgramId = 'source-program-only'")) + } + + @Test + fun deleteRecoversACompleteReadyContextAfterPostCommitPublicationFailure() = runTest { + ready() + val created = repository.createAndActivateProfile("Jordan", 1) + preferenceStore.failNextGetFor = "default" + + assertTrue(repository.deleteProfile(created.id)) + + assertNull(database.vitruvianDatabaseQueries.getProfileById(created.id).executeAsOneOrNull()) + assertEquals("default", database.vitruvianDatabaseQueries.getActiveProfile().executeAsOne().id) + assertEquals( + "default", + assertIs(repository.activeProfileContext.value).profile.id, + ) + } + + @Test + fun fakeRepositoryMatchesSectionIsolationSwitchingAndMigrationSemantics() = runTest { + val fake = FakeUserProfileRepository() + fake.ensureDefaultProfile() + assertEquals(0, fake.observePreferences("default").first().legacyMigrationVersion) + fake.reconcileActiveProfileContext() + val profileA = fake.createAndActivateProfile("A", 1) + val rackA = RackPreferences(items = listOf(RackItem(id = "a", name = "A", weightKg = 5f))) + val workoutA = WorkoutPreferences(stopAtTop = true) + val ledA = LedPreferences(colorScheme = 3, discoModeUnlocked = true) + val vbtA = VbtPreferences(enabled = false, velocityLossThresholdPercent = 30) + val safetyA = ProfileLocalSafetyPreferences("red", true, true, false) + fake.updateCore(profileA.id, CoreProfilePreferences(bodyWeightKg = 80f)) + fake.updateRack(profileA.id, rackA) + fake.updateWorkout(profileA.id, workoutA) + fake.updateLed(profileA.id, ledA) + fake.updateVbt(profileA.id, vbtA) + fake.updateLocalSafety(profileA.id, safetyA) + + val observed = mutableListOf() + val job = launch(UnconfinedTestDispatcher(testScheduler)) { + fake.activeProfileContext.drop(1).take(2).toList(observed) + } + val profileB = fake.createAndActivateProfile("B", 2) + job.cancel() + assertEquals(profileB.id, assertIs(observed[0]).targetProfileId) + assertEquals(profileB.id, assertIs(observed[1]).profile.id) + assertEquals(1, fake.observePreferences(profileB.id).first().legacyMigrationVersion) + + fake.setActiveProfile(profileA.id) + val readyA = assertIs(fake.activeProfileContext.value) + assertEquals(80f, readyA.preferences.core.value.bodyWeightKg) + assertEquals(rackA, readyA.preferences.rack.value) + assertEquals(workoutA, readyA.preferences.workout.value) + assertEquals(ledA, readyA.preferences.led.value) + assertEquals(vbtA, readyA.preferences.vbt.value) + assertEquals(safetyA, readyA.localSafety) + + assertFailsWith { + fake.observePreferences("missing").first() + } + } + + @Test + fun fakeRecoveryAndCleanupHooksRetainSwitchingUntilReconciliation() = runTest { + val fake = FakeUserProfileRepository() + fake.ensureDefaultProfile() + fake.reconcileActiveProfileContext() + fake.updateLocalSafety( + "default", + ProfileLocalSafetyPreferences("phrase", true, true, true), + ) + fake.pendingLocalCleanupProfileIds += "default" + + fake.retryPendingLocalCleanup() + assertTrue(fake.pendingLocalCleanupProfileIds.isEmpty()) + fake.setActiveProfile("default") + assertEquals( + ProfileLocalSafetyPreferences(), + assertIs(fake.activeProfileContext.value).localSafety, + ) + + fake.recoverPendingProfileTransitionForStartup() + assertIs(fake.activeProfileContext.value) + fake.reconcileActiveProfileContext() + assertIs(fake.activeProfileContext.value) + } + + @Test + fun fakeDeletionMatchesFailureRollbackAndRetryableCleanupSemantics() = runTest { + val fake = FakeUserProfileRepository() + fake.ensureDefaultProfile() + fake.reconcileActiveProfileContext() + val source = fake.createAndActivateProfile("Source", 1) + val safety = ProfileLocalSafetyPreferences("source-secret", true, true, true) + fake.updateCore(source.id, CoreProfilePreferences(bodyWeightKg = 83f)) + fake.updateLocalSafety(source.id, safety) + fake.failBeforeProfileDeletionCommit = true + val failureEvents = mutableListOf() + val failureJob = launch(UnconfinedTestDispatcher(testScheduler)) { + fake.activeProfileContext.drop(1).take(2).toList(failureEvents) + } + + assertFailsWith { fake.deleteProfile(source.id) } + + assertEquals("default", assertIs(failureEvents[0]).targetProfileId) + assertEquals(source.id, assertIs(failureEvents[1]).profile.id) + assertEquals(83f, fake.observePreferences(source.id).first().core.value.bodyWeightKg) + assertEquals(safety, assertIs(fake.activeProfileContext.value).localSafety) + assertTrue(fake.pendingLocalCleanupProfileIds.isEmpty()) + failureJob.cancel() + + fake.failLocalCleanupDeletes = true + assertTrue(fake.deleteProfile(source.id)) + assertEquals(setOf(source.id), fake.pendingLocalCleanupProfileIds) + fake.failLocalCleanupDeletes = false + fake.retryPendingLocalCleanup() + assertTrue(fake.pendingLocalCleanupProfileIds.isEmpty()) + } + + @Test + fun fakeFailedDeletionPublishesNoIdentityChangesAndKeepsExistingPreferenceCollectorsLive() = runTest { + val fake = FakeUserProfileRepository() + fake.ensureDefaultProfile() + fake.reconcileActiveProfileContext() + val source = fake.createAndActivateProfile("Source", 1) + fake.updateCore(source.id, CoreProfilePreferences(bodyWeightKg = 83f)) + val preferenceEvents = mutableListOf() + val activeEvents = mutableListOf() + val allProfileEvents = mutableListOf>() + val preferenceJob = launch(UnconfinedTestDispatcher(testScheduler)) { + fake.observePreferences(source.id).take(2).collect { + preferenceEvents += it.core.value.bodyWeightKg + } + } + val activeJob = launch(UnconfinedTestDispatcher(testScheduler)) { + fake.activeProfile.drop(1).toList(activeEvents) + } + val allProfilesJob = launch(UnconfinedTestDispatcher(testScheduler)) { + fake.allProfiles.drop(1).toList(allProfileEvents) + } + fake.failBeforeProfileDeletionCommit = true + + assertFailsWith { fake.deleteProfile(source.id) } + fake.updateCore(source.id, CoreProfilePreferences(bodyWeightKg = 84f)) + preferenceJob.cancel() + activeJob.cancel() + allProfilesJob.cancel() + + assertEquals(listOf(83f, 84f), preferenceEvents) + assertTrue(activeEvents.isEmpty(), "failed transaction leaked active-profile identity") + assertTrue(allProfileEvents.isEmpty(), "failed transaction leaked profile-list identity") + } + + @Test + fun deleteProfileRecomputesMergedGamificationStatsWithoutDuplicateTargetRows() = runTest { + ready() val created = repository.createProfile("Jordan", 1) val gamificationRepository = SqlDelightGamificationRepository(database) - insertWorkoutSession(id = "session-default", totalReps = 10, weightPerCableKg = 20.0, profileId = "default") - insertWorkoutSession(id = "session-created", totalReps = 12, weightPerCableKg = 25.0, profileId = created.id) - + insertWorkoutSession("session-default", 10, 20.0, "default") + insertWorkoutSession("session-created", 12, 25.0, created.id) gamificationRepository.updateStats("default") gamificationRepository.updateStats(created.id) - assertEquals(2, database.vitruvianDatabaseQueries.selectGamificationStatsSync().executeAsList().size) - val deleted = repository.deleteProfile(created.id) - - assertTrue(deleted) + assertTrue(repository.deleteProfile(created.id)) val remainingRows = database.vitruvianDatabaseQueries .selectGamificationStatsSync() .executeAsList() .filter { it.profile_id == "default" } assertEquals(1, remainingRows.size) - - val merged = database.vitruvianDatabaseQueries.selectGamificationStats("default").executeAsOneOrNull() + val merged = database.vitruvianDatabaseQueries + .selectGamificationStats("default") + .executeAsOneOrNull() assertNotNull(merged) assertEquals(2, merged.totalWorkouts.toInt()) assertEquals(22, merged.totalReps.toInt()) } - private fun insertWorkoutSession(id: String, totalReps: Long, weightPerCableKg: Double, profileId: String) { + private suspend fun ready() { + preferenceStore.seedMissingProfiles() + repository.reconcileActiveProfileContext() + } + + private fun preferenceIds(): Set = database.vitruvianDatabaseQueries + .selectAllProfilePreferences() + .executeAsList() + .map { it.profile_id } + .toSet() + + private fun createDatabase(driver: SqlDriver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)): VitruvianDatabase { + VitruvianDatabase.Schema.create(driver) + return VitruvianDatabase(driver) + } + + private fun createRepository( + database: VitruvianDatabase, + preferences: ProfilePreferencesRepository, + safety: ProfileLocalSafetyStore, + beforeProfileDeletionCommit: () -> Unit = {}, + ) = SqlDelightUserProfileRepository( + database = database, + profilePreferencesRepository = preferences, + profileLocalSafetyStore = safety, + gamificationRepository = SqlDelightGamificationRepository(database), + beforeProfileDeletionCommit = beforeProfileDeletionCommit, + ) + + private fun executeSql(sql: String, vararg values: Any?) { + sqlDriver.execute(null, sql, values.size) { + values.forEachIndexed { index, value -> + when (value) { + null -> bindString(index, null) + is String -> bindString(index, value) + is Long -> bindLong(index, value) + is Int -> bindLong(index, value.toLong()) + is Double -> bindDouble(index, value) + else -> error("Unsupported SQL value: $value") + } + } + } + } + + private fun countRows(table: String, column: String, value: String): Int = + countRowsWhere(table, "$column = '$value'") + + private fun countRowsWhere(table: String, where: String): Int { + var count = 0 + sqlDriver.executeQuery( + null, + "SELECT COUNT(*) FROM $table WHERE $where", + { cursor -> + if (cursor.next().value) count = cursor.getLong(0)?.toInt() ?: 0 + QueryResult.Value(Unit) + }, + 0, + ) + return count + } + + private fun textValue(sql: String): String? { + var result: String? = null + sqlDriver.executeQuery( + null, + sql, + { cursor -> + if (cursor.next().value) result = cursor.getString(0) + QueryResult.Value(Unit) + }, + 0, + ) + return result + } + + private fun seedExternalLeafPairs(sourceProfileId: String) { + executeSql("INSERT INTO ExternalActivity(id, externalId, provider, name, startedAt, syncedAt, profileId, rawData) VALUES ('target-activity', 'shared-activity', 'hevy', 'Target', 1, 1, 'default', 'target-bytes')") + executeSql("INSERT INTO ExternalActivity(id, externalId, provider, name, startedAt, syncedAt, profileId, rawData) VALUES ('source-activity', 'shared-activity', 'hevy', 'Source', 1, 1, ?, 'source-bytes')", sourceProfileId) + executeSql("INSERT INTO ExternalActivity(id, externalId, provider, name, startedAt, syncedAt, profileId, rawData) VALUES ('source-activity-only', 'source-activity-only', 'hevy', 'Only', 1, 1, ?, 'source-only-bytes')", sourceProfileId) + executeSql("INSERT INTO ExternalRoutineFolder(id, externalId, provider, title, profileId, rawData) VALUES ('target-folder', 'shared-folder', 'hevy', 'Target', 'default', 'target-bytes')") + executeSql("INSERT INTO ExternalRoutineFolder(id, externalId, provider, title, profileId, rawData) VALUES ('source-folder', 'shared-folder', 'hevy', 'Source', ?, 'source-bytes')", sourceProfileId) + executeSql("INSERT INTO ExternalRoutineFolder(id, externalId, provider, title, profileId, rawData) VALUES ('source-folder-only', 'source-folder-only', 'hevy', 'Only', ?, 'source-only-bytes')", sourceProfileId) + executeSql("INSERT INTO ExternalProgram(id, externalId, provider, name, syncedAt, profileId, rawData) VALUES ('target-program', 'shared-program', 'hevy', 'Target', 1, 'default', 'target-bytes')") + executeSql("INSERT INTO ExternalProgram(id, externalId, provider, name, syncedAt, profileId, rawData) VALUES ('source-program-conflict', 'shared-program', 'hevy', 'Source', 1, ?, 'source-bytes')", sourceProfileId) + executeSql("INSERT INTO ExternalProgramStats(id, externalProgramId, rawData) VALUES ('target-stats', 'target-program', 'target-stats-bytes')") + executeSql("INSERT INTO ExternalProgramStats(id, externalProgramId, rawData) VALUES ('source-conflict-stats', 'source-program-conflict', 'source-stats-bytes')") + executeSql("INSERT INTO ExternalProgram(id, externalId, provider, name, syncedAt, profileId, rawData) VALUES ('source-program-only', 'source-only-program', 'hevy', 'Only', 1, ?, 'source-only-bytes')", sourceProfileId) + executeSql("INSERT INTO ExternalProgramStats(id, externalProgramId, rawData) VALUES ('source-only-stats', 'source-program-only', 'source-only-stats-bytes')") + executeSql("INSERT INTO ExternalExerciseTemplate(id, externalId, provider, title, profileId, rawData) VALUES ('target-template', 'shared-template', 'hevy', 'Target', 'default', 'target-bytes')") + executeSql("INSERT INTO ExternalExerciseTemplate(id, externalId, provider, title, profileId, rawData) VALUES ('source-template', 'shared-template', 'hevy', 'Source', ?, 'source-bytes')", sourceProfileId) + executeSql("INSERT INTO ExternalExerciseTemplate(id, externalId, provider, title, profileId, rawData) VALUES ('source-template-only', 'source-template-only', 'hevy', 'Only', ?, 'source-only-bytes')", sourceProfileId) + executeSql("INSERT INTO ExternalExerciseTemplateMapping(id, provider, externalTemplateId, localExerciseId, profileId, createdAt, updatedAt, rawData) VALUES ('target-mapping', 'hevy', 'shared-template', 'bench', 'default', 1, 1, 'target-bytes')") + executeSql("INSERT INTO ExternalExerciseTemplateMapping(id, provider, externalTemplateId, localExerciseId, profileId, createdAt, updatedAt, rawData) VALUES ('source-mapping', 'hevy', 'shared-template', 'squat', ?, 1, 1, 'source-bytes')", sourceProfileId) + executeSql("INSERT INTO ExternalExerciseTemplateMapping(id, provider, externalTemplateId, localExerciseId, profileId, createdAt, updatedAt, rawData) VALUES ('source-mapping-only', 'hevy', 'source-template-only', 'row', ?, 1, 1, 'source-only-bytes')", sourceProfileId) + executeSql("INSERT INTO ExternalBodyMeasurement(id, externalId, provider, measurementType, value, unit, measuredAt, syncedAt, profileId, rawData) VALUES ('target-body', 'shared-body', 'health', 'weight', 80, 'kg', 1, 1, 'default', 'target-bytes')") + executeSql("INSERT INTO ExternalBodyMeasurement(id, externalId, provider, measurementType, value, unit, measuredAt, syncedAt, profileId, rawData) VALUES ('source-body', 'shared-body', 'health', 'weight', 90, 'kg', 1, 1, ?, 'source-bytes')", sourceProfileId) + executeSql("INSERT INTO ExternalBodyMeasurement(id, externalId, provider, measurementType, value, unit, measuredAt, syncedAt, profileId, rawData) VALUES ('source-body-only', 'source-body-only', 'health', 'weight', 90, 'kg', 1, 1, ?, 'source-only-bytes')", sourceProfileId) + } + + private fun createFaultingFixture(): FaultingFixture { + val transitionFaults = TransitionFaults() + val driver = FaultInjectingSqlDriver( + JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY), + transitionFaults, + ) + val fixtureDatabase = createDatabase(driver) + val fixturePreferences = FaultingProfilePreferencesRepository( + SqlDelightProfilePreferencesRepository(fixtureDatabase), + ) + val fixtureSafety = FaultingProfileLocalSafetyStore( + SettingsProfileLocalSafetyStore(MapSettings()), + ) + return FaultingFixture( + database = fixtureDatabase, + preferenceStore = fixturePreferences, + repository = createRepository(fixtureDatabase, fixturePreferences, fixtureSafety), + transitionFaults = transitionFaults, + ) + } + + private fun insertWorkoutSession( + id: String, + totalReps: Long, + weightPerCableKg: Double, + profileId: String, + ) { database.vitruvianDatabaseQueries.insertSession( id = id, timestamp = 1_000_000L, @@ -138,4 +1305,116 @@ class SqlDelightUserProfileRepositoryTest { rackItemsJson = "[]", ) } + + private data class FaultingFixture( + val database: VitruvianDatabase, + val preferenceStore: FaultingProfilePreferencesRepository, + val repository: SqlDelightUserProfileRepository, + val transitionFaults: TransitionFaults, + ) + + private class FaultingProfilePreferencesRepository( + private val delegate: ProfilePreferencesRepository, + ) : ProfilePreferencesRepository by delegate { + var failNextGet = false + var failNextGetFor: String? = null + var cancelNextGetWith: CancellationException? = null + private var requireActiveGetContext = false + + override suspend fun get(profileId: String): UserProfilePreferences { + cancelNextGetWith?.let { cancellation -> + cancelNextGetWith = null + requireActiveGetContext = true + currentCoroutineContext().cancel(cancellation) + throw cancellation + } + if (requireActiveGetContext) currentCoroutineContext().ensureActive() + return when { + failNextGet -> { + failNextGet = false + throw InjectedTransitionFailure() + } + + failNextGetFor == profileId -> { + failNextGetFor = null + throw InjectedTransitionFailure() + } + + else -> delegate.get(profileId) + } + } + } + + private class FaultingProfileLocalSafetyStore( + private val delegate: ProfileLocalSafetyStore, + ) : ProfileLocalSafetyStore by delegate { + var failDeletes = false + + override fun delete(profileId: String) { + if (failDeletes) throw InjectedTransitionFailure() + delegate.delete(profileId) + } + } + + private data class TransitionFaults( + var failSetActiveAfterMatches: Int? = null, + var failNextProfileDelete: Boolean = false, + var failNextJournalClear: Boolean = false, + ) + + private class FaultInjectingSqlDriver( + private val delegate: SqlDriver, + private val faults: TransitionFaults, + ) : SqlDriver by delegate { + override fun execute( + identifier: Int?, + sql: String, + parameters: Int, + binders: (SqlPreparedStatement.() -> Unit)?, + ): QueryResult { + if (identifier == SET_ACTIVE_PROFILE_IDENTIFIER) { + faults.failSetActiveAfterMatches?.let { remaining -> + if (remaining == 0) { + faults.failSetActiveAfterMatches = null + throw InjectedTransitionFailure() + } + faults.failSetActiveAfterMatches = remaining - 1 + } + } + if (identifier == DELETE_PROFILE_IDENTIFIER && faults.failNextProfileDelete) { + faults.failNextProfileDelete = false + throw InjectedTransitionFailure() + } + if (identifier == CLEAR_RECOVERY_JOURNAL_IDENTIFIER && faults.failNextJournalClear) { + faults.failNextJournalClear = false + throw InjectedTransitionFailure() + } + return delegate.execute(identifier, sql, parameters, binders) + } + + private companion object { + const val SET_ACTIVE_PROFILE_IDENTIFIER = 373_348_112 + const val DELETE_PROFILE_IDENTIFIER = 787_673_935 + const val CLEAR_RECOVERY_JOURNAL_IDENTIFIER = 1_230_173_044 + } + } + + private class InjectedTransitionFailure : IllegalStateException("injected transition failure") + + private class OneShotRemoveFailingSettings( + private val failAtRemove: Int, + private val delegate: Settings = MapSettings(), + ) : Settings by delegate { + private var removeCount = 0 + private var failed = false + + override fun remove(key: String) { + removeCount += 1 + if (!failed && removeCount == failAtRemove) { + failed = true + throw InjectedTransitionFailure() + } + delegate.remove(key) + } + } } diff --git a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightWorkoutRepositoryTest.kt b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightWorkoutRepositoryTest.kt index 36abd6c67..396e3df93 100644 --- a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightWorkoutRepositoryTest.kt +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightWorkoutRepositoryTest.kt @@ -9,6 +9,7 @@ import com.devil.phoenixproject.domain.model.WorkoutSession import com.devil.phoenixproject.testutil.FakeExerciseRepository import com.devil.phoenixproject.testutil.createTestDatabase import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -92,6 +93,66 @@ class SqlDelightWorkoutRepositoryTest { } } + @Test + fun `recent completed sessions are profile exercise scoped newest first and limited`() = runTest { + repository.saveSession(workoutSession("old", "a", "bench", 10L, workingReps = 5)) + repository.saveSession(workoutSession("total-only", "a", "bench", 20L, workingReps = 0, totalReps = 4)) + repository.saveSession(workoutSession("a-tie", "a", "bench", 30L, workingReps = 3)) + repository.saveSession(workoutSession("z-tie", "a", "bench", 30L, workingReps = 3)) + repository.saveSession(workoutSession("wrong-exercise", "a", "squat", 50L, workingReps = 5)) + repository.saveSession(workoutSession("wrong-profile", "b", "bench", 60L, workingReps = 5)) + repository.saveSession(workoutSession("zero", "a", "bench", 70L, workingReps = 0, totalReps = 0)) + repository.saveSession(workoutSession("deleted", "a", "bench", 80L, workingReps = 5)) + database.vitruvianDatabaseQueries.softDeleteSession( + id = "deleted", + deletedAt = 81L, + updatedAt = 81L, + ) + + val result = repository.getRecentCompletedSessionsForExercise( + exerciseId = "bench", + profileId = "a", + limit = 3, + ) + + assertEquals(listOf("z-tie", "a-tie", "total-only"), result.map { it.id }) + } + + @Test + fun `most recent completed exercise uses deterministic live eligible row`() = runTest { + repository.saveSession(workoutSession("a-tie", "a", "bench", 30L, workingReps = 5)) + repository.saveSession(workoutSession("z-tie", "a", "squat", 30L, workingReps = 0, totalReps = 2)) + repository.saveSession(workoutSession("ghost", "a", "deadlift", 50L, workingReps = 0, totalReps = 0)) + repository.saveSession(workoutSession("blank-exercise", "a", " ", 55L, workingReps = 5)) + repository.saveSession(workoutSession("other-profile", "b", "row", 60L, workingReps = 5)) + repository.saveSession(workoutSession("deleted", "a", "press", 70L, workingReps = 5)) + database.vitruvianDatabaseQueries.softDeleteSession( + id = "deleted", + deletedAt = 71L, + updatedAt = 71L, + ) + + assertEquals("squat", repository.getMostRecentCompletedExerciseId("a")) + } + + @Test + fun `bounded reads reject blank ownership and limits outside one through five`() = runTest { + assertFailsWith { + repository.getRecentCompletedSessionsForExercise(" ", "a", 1) + } + assertFailsWith { + repository.getRecentCompletedSessionsForExercise("bench", " ", 1) + } + listOf(0, -1, MAX_RECENT_EXERCISE_SESSIONS + 1).forEach { invalidLimit -> + assertFailsWith { + repository.getRecentCompletedSessionsForExercise("bench", "a", invalidLimit) + } + } + assertFailsWith { + repository.getMostRecentCompletedExerciseId(" ") + } + } + @Test fun `deleteAllSessions removes all sessions`() = runTest { repository.saveSession(createTestSession(id = "session-1")) @@ -398,6 +459,27 @@ class SqlDelightWorkoutRepositoryTest { exerciseName = "Test Exercise", ) + private fun workoutSession( + id: String, + profileId: String, + exerciseId: String, + timestamp: Long, + workingReps: Int, + totalReps: Int = workingReps, + ): WorkoutSession = WorkoutSession( + id = id, + timestamp = timestamp, + mode = "OldSchool", + reps = totalReps, + weightPerCableKg = 40f, + duration = 1_000L, + totalReps = totalReps, + workingReps = workingReps, + exerciseId = exerciseId, + exerciseName = exerciseId, + profileId = profileId, + ) + // ========== ISSUE #586: ROUTINE-TIME-ESTIMATION SQL ELIGIBILITY ========== /** diff --git a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/sync/SqlDelightProfilePreferenceSyncRepositoryTest.kt b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/sync/SqlDelightProfilePreferenceSyncRepositoryTest.kt new file mode 100644 index 000000000..9049a387f --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/sync/SqlDelightProfilePreferenceSyncRepositoryTest.kt @@ -0,0 +1,1236 @@ +package com.devil.phoenixproject.data.sync + +import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver +import com.devil.phoenixproject.data.repository.SqlDelightProfilePreferencesRepository +import com.devil.phoenixproject.database.VitruvianDatabase +import com.devil.phoenixproject.domain.model.CoreProfilePreferences +import com.devil.phoenixproject.domain.model.LedPreferences +import com.devil.phoenixproject.domain.model.ProfilePreferenceSectionName +import com.devil.phoenixproject.domain.model.RackItem +import com.devil.phoenixproject.domain.model.RackPreferences +import com.devil.phoenixproject.domain.model.SingleExerciseDefaultsDocument +import com.devil.phoenixproject.domain.model.UserProfilePreferences +import com.devil.phoenixproject.domain.model.VbtPreferences +import com.devil.phoenixproject.domain.model.WeightUnit +import com.devil.phoenixproject.domain.model.WorkoutPreferences +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import kotlinx.serialization.json.putJsonObject +import org.junit.After +import org.junit.Before +import org.junit.Test + +class SqlDelightProfilePreferenceSyncRepositoryTest { + private lateinit var driver: JdbcSqliteDriver + private lateinit var database: VitruvianDatabase + private lateinit var foundationRepository: SqlDelightProfilePreferencesRepository + private lateinit var codec: ProfilePreferenceSyncCodec + private lateinit var repository: SqlDelightProfilePreferenceSyncRepository + + @Before + fun setup() { + driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) + VitruvianDatabase.Schema.create(driver) + database = VitruvianDatabase(driver) + foundationRepository = SqlDelightProfilePreferencesRepository(database) + codec = ProfilePreferenceSyncCodec() + repository = SqlDelightProfilePreferenceSyncRepository(database, codec) + } + + @After + fun tearDown() { + driver.close() + } + + private fun createProfile(id: String) { + database.vitruvianDatabaseQueries.insertProfile(id, id, 0, 1, 0) + } + + private fun assertProfileDoesNotExist(id: String) { + assertNull(database.vitruvianDatabaseQueries.getProfileById(id).executeAsOneOrNull()) + } + + @Test + fun `dirty snapshot reconstructs row owned LED and VBT values as objects`() = runTest { + createProfile("profile-a") + foundationRepository.insertDefaults("profile-a") + foundationRepository.updateLed( + "profile-a", + LedPreferences(colorScheme = 7, discoModeUnlocked = true), + now = 20, + ) + foundationRepository.updateVbt( + "profile-a", + VbtPreferences(enabled = false, velocityLossThresholdPercent = 35), + now = 30, + ) + + val snapshot = repository.snapshotDirtySections() + val led = snapshot.valid.single { it.key.section == ProfilePreferenceSectionName.LED } + val vbt = snapshot.valid.single { it.key.section == ProfilePreferenceSectionName.VBT } + + assertEquals(7, led.payload.getValue("ledColorSchemeId").jsonPrimitive.int) + assertTrue(led.payload.getValue("preferences") is JsonObject) + assertNull(led.payload.getValue("preferences").jsonObject["colorScheme"]) + assertFalse(vbt.payload.getValue("vbtEnabled").jsonPrimitive.boolean) + assertTrue(vbt.payload.getValue("preferences") is JsonObject) + assertNull(vbt.payload.getValue("preferences").jsonObject["enabled"]) + assertFalse(led.payload.toString().contains("\\\"{", ignoreCase = false)) + assertFalse(vbt.payload.toString().contains("\\\"{", ignoreCase = false)) + } + + @Test + fun `malformed dirty document is reported and valid sibling remains syncable`() = runTest { + createProfile("profile-a") + foundationRepository.insertDefaults("profile-a") + database.vitruvianDatabaseQueries.updateWorkoutProfilePreferences( + workout_preferences_json = "{broken", + workout_updated_at = 20, + profile_id = "profile-a", + ) + foundationRepository.updateRack( + "profile-a", + RackPreferences(items = listOf(RackItem(id = "rack-1", name = "Bar", weightKg = 20f))), + now = 30, + ) + + val snapshot = repository.snapshotDirtySections() + + assertTrue(snapshot.valid.any { it.key.section == ProfilePreferenceSectionName.RACK }) + assertTrue(snapshot.valid.none { it.key.section == ProfilePreferenceSectionName.WORKOUT }) + assertEquals(ProfilePreferenceSectionName.WORKOUT, snapshot.unsyncable.single().key.section) + assertEquals( + ProfilePreferenceSyncIssueReason.INVALID_LOCAL_DOCUMENT.name, + snapshot.unsyncable.single().reason, + ) + } + + private fun forceDirtyCoreRevision(profileId: String, revision: Long) { + driver.execute( + identifier = null, + sql = """ + UPDATE UserProfilePreferences + SET core_server_revision = ?, core_dirty = 1 + WHERE profile_id = ? + """.trimIndent(), + parameters = 2, + ) { + bindLong(0, revision) + bindString(1, profileId) + } + } + + @Test + fun `base revision max is syncable while max plus one is a dead letter`() = runTest { + listOf( + "profile-max" to MAX_EXACT_JSON_INTEGER, + "profile-over" to MAX_EXACT_JSON_INTEGER + 1, + ).forEach { (profileId, revision) -> + createProfile(profileId) + foundationRepository.insertDefaults(profileId) + forceDirtyCoreRevision(profileId, revision) + } + + val first = repository.snapshotDirtySections() + val max = first.valid.single { + it.key.localProfileId == "profile-max" && + it.key.section == ProfilePreferenceSectionName.CORE + } + assertEquals(MAX_EXACT_JSON_INTEGER, max.baseRevision) + assertTrue(first.valid.none { + it.key.localProfileId == "profile-over" && + it.key.section == ProfilePreferenceSectionName.CORE + }) + val issue = first.unsyncable.single { + it.key.localProfileId == "profile-over" && + it.key.section == ProfilePreferenceSectionName.CORE + } + assertEquals( + foundationRepository.get("profile-over").core.metadata.localGeneration, + issue.localGeneration, + ) + assertEquals(ProfilePreferenceSyncIssueReason.UNREPRESENTABLE_JSON_INTEGER.name, issue.reason) + assertTrue(foundationRepository.get("profile-over").core.metadata.dirty) + + val second = repository.snapshotDirtySections() + assertEquals( + setOf("profile-over"), + second.unsyncable.filter { it.key.section == ProfilePreferenceSectionName.CORE } + .map { it.key.localProfileId } + .toSet(), + ) + } + + @Test + fun `rack signed timestamp bounds stay JSON numbers and overflow is dead lettered`() = runTest { + suspend fun writeRack( + profileId: String, + createdAt: Long, + updatedAt: Long, + duplicateName: Boolean = false, + ) { + createProfile(profileId) + foundationRepository.insertDefaults(profileId) + val items = buildList { + add( + RackItem( + id = "rack-1", + name = "Same name", + weightKg = 20f, + createdAt = createdAt, + updatedAt = updatedAt, + ), + ) + if (duplicateName) { + add( + RackItem( + id = "rack-2", + name = "Same name", + weightKg = 10f, + createdAt = createdAt, + updatedAt = updatedAt, + ), + ) + } + } + foundationRepository.updateRack(profileId, RackPreferences(items = items), now = 30) + } + writeRack( + "rack-bounds", + createdAt = MIN_EXACT_JSON_INTEGER, + updatedAt = MAX_EXACT_JSON_INTEGER, + duplicateName = true, + ) + writeRack("rack-created-over", MAX_EXACT_JSON_INTEGER + 1, 0) + writeRack("rack-updated-under", 0, MIN_EXACT_JSON_INTEGER - 1) + + val snapshot = repository.snapshotDirtySections() + val bounds = snapshot.valid.single { + it.key.localProfileId == "rack-bounds" && + it.key.section == ProfilePreferenceSectionName.RACK + } + val firstItem = bounds.payload.getValue("items").jsonArray.first().jsonObject + assertFalse(firstItem.getValue("createdAt").jsonPrimitive.isString) + assertFalse(firstItem.getValue("updatedAt").jsonPrimitive.isString) + assertEquals(MIN_EXACT_JSON_INTEGER, firstItem.getValue("createdAt").jsonPrimitive.long) + assertEquals(MAX_EXACT_JSON_INTEGER, firstItem.getValue("updatedAt").jsonPrimitive.long) + assertEquals(2, bounds.payload.getValue("items").jsonArray.size) + + mapOf( + "rack-created-over" to "RACK.items[0].createdAt", + "rack-updated-under" to "RACK.items[0].updatedAt", + ).forEach { (profileId, field) -> + assertTrue(snapshot.valid.none { + it.key.localProfileId == profileId && + it.key.section == ProfilePreferenceSectionName.RACK + }) + val issue = snapshot.unsyncable.single { + it.key.localProfileId == profileId && + it.key.section == ProfilePreferenceSectionName.RACK + } + assertEquals( + ProfilePreferenceSyncIssueReason.UNREPRESENTABLE_JSON_INTEGER.name, + issue.reason, + field, + ) + assertTrue(foundationRepository.get(profileId).rack.metadata.dirty) + } + + val oldGeneration = snapshot.unsyncable.single { + it.key.localProfileId == "rack-created-over" && + it.key.section == ProfilePreferenceSectionName.RACK + }.localGeneration + foundationRepository.updateRack( + "rack-created-over", + RackPreferences( + items = listOf( + RackItem( + id = "rack-1", + name = "Same name", + weightKg = 20f, + createdAt = 0, + updatedAt = 0, + ), + ), + ), + now = 40, + ) + val revalidated = repository.snapshotDirtySections().valid.single { + it.key.localProfileId == "rack-created-over" && + it.key.section == ProfilePreferenceSectionName.RACK + } + assertTrue(revalidated.localGeneration > oldGeneration) + } + + private fun forceDirtyLedColorScheme(profileId: String, colorScheme: Long) { + driver.execute( + identifier = null, + sql = """ + UPDATE UserProfilePreferences + SET led_color_scheme_id = ?, led_dirty = 1 + WHERE profile_id = ? + """.trimIndent(), + parameters = 2, + ) { + bindLong(0, colorScheme) + bindString(1, profileId) + } + } + + @Test + fun `LED color scheme validates Int32 before conversion and never wraps`() = runTest { + mapOf( + "led-max" to Int.MAX_VALUE.toLong(), + "led-over" to Int.MAX_VALUE.toLong() + 1, + "led-wraps-to-zero" to 4_294_967_296L, + ).forEach { (profileId, value) -> + createProfile(profileId) + foundationRepository.insertDefaults(profileId) + forceDirtyLedColorScheme(profileId, value) + } + + val snapshot = repository.snapshotDirtySections() + val max = snapshot.valid.single { + it.key.localProfileId == "led-max" && it.key.section == ProfilePreferenceSectionName.LED + } + assertEquals(Int.MAX_VALUE, max.payload.getValue("ledColorSchemeId").jsonPrimitive.int) + listOf("led-over", "led-wraps-to-zero").forEach { profileId -> + assertTrue(snapshot.valid.none { + it.key.localProfileId == profileId && it.key.section == ProfilePreferenceSectionName.LED + }) + assertEquals( + ProfilePreferenceSyncIssueReason.INVALID_INT32.name, + snapshot.unsyncable.single { + it.key.localProfileId == profileId && + it.key.section == ProfilePreferenceSectionName.LED + }.reason, + ) + } + } + + private fun singleExerciseDefaults(exerciseId: String) = SingleExerciseDefaultsDocument( + exerciseId = exerciseId, + setReps = emptyList(), + weightPerCableKg = 20f, + setWeightsPerCableKg = emptyList(), + progressionKg = 0f, + setRestSeconds = emptyList(), + workoutModeId = 0, + eccentricLoadPercentage = 100, + echoLevelValue = 1, + duration = 0, + isAMRAP = false, + perSetRestTime = false, + ) + + @Test + fun `Postgres incompatible text and normalized local only keys dead letter only their sections`() = runTest { + createProfile("nul-profile") + foundationRepository.insertDefaults("nul-profile") + foundationRepository.updateRack( + "nul-profile", + RackPreferences( + items = listOf( + RackItem(id = "rack-nul", name = "SECRET_SENTINEL\u0000", weightKg = 20f), + ), + ), + now = 20, + ) + + createProfile("surrogate-profile") + foundationRepository.insertDefaults("surrogate-profile") + database.vitruvianDatabaseQueries.updateRackProfilePreferences( + equipment_rack_json = + """{"version":1,"items":[{"id":"rack-surrogate","name":"bad${'\\'}uD800","weightKg":20}]}""", + rack_updated_at = 20, + profile_id = "surrogate-profile", + ) + + val forbiddenKey = "safeWord\u0130" + createProfile("local-key-profile") + foundationRepository.insertDefaults("local-key-profile") + foundationRepository.updateWorkout( + "local-key-profile", + WorkoutPreferences( + singleExerciseDefaults = mapOf( + forbiddenKey to singleExerciseDefaults(forbiddenKey), + ), + ), + now = 20, + ) + + val snapshot = repository.snapshotDirtySections() + mapOf( + ProfilePreferenceSectionKey("nul-profile", ProfilePreferenceSectionName.RACK) to + ProfilePreferenceSyncIssueReason.INVALID_TEXT_TREE, + ProfilePreferenceSectionKey("surrogate-profile", ProfilePreferenceSectionName.RACK) to + ProfilePreferenceSyncIssueReason.INVALID_TEXT_TREE, + ProfilePreferenceSectionKey("local-key-profile", ProfilePreferenceSectionName.WORKOUT) to + ProfilePreferenceSyncIssueReason.LOCAL_ONLY_WIRE_KEY, + ).forEach { (key, expectedReason) -> + assertTrue(snapshot.valid.none { it.key == key }) + val issue = snapshot.unsyncable.single { it.key == key } + assertEquals(expectedReason.name, issue.reason) + assertFalse(issue.reason.contains("SECRET_SENTINEL")) + assertFalse(issue.reason.contains(forbiddenKey)) + } + assertTrue(snapshot.valid.any { + it.key == ProfilePreferenceSectionKey("nul-profile", ProfilePreferenceSectionName.CORE) + }) + assertTrue(snapshot.valid.any { + it.key == ProfilePreferenceSectionKey("local-key-profile", ProfilePreferenceSectionName.RACK) + }) + } + + private fun forceDirtySectionUpdatedAt( + profileId: String, + section: ProfilePreferenceSectionName, + updatedAt: Long, + ) { + val updatedAtColumn = when (section) { + ProfilePreferenceSectionName.CORE -> "core_updated_at" + ProfilePreferenceSectionName.RACK -> "rack_updated_at" + ProfilePreferenceSectionName.WORKOUT -> "workout_updated_at" + ProfilePreferenceSectionName.LED -> "led_updated_at" + ProfilePreferenceSectionName.VBT -> "vbt_updated_at" + } + val dirtyColumn = when (section) { + ProfilePreferenceSectionName.CORE -> "core_dirty" + ProfilePreferenceSectionName.RACK -> "rack_dirty" + ProfilePreferenceSectionName.WORKOUT -> "workout_dirty" + ProfilePreferenceSectionName.LED -> "led_dirty" + ProfilePreferenceSectionName.VBT -> "vbt_dirty" + } + driver.execute( + identifier = null, + sql = """ + UPDATE UserProfilePreferences + SET $updatedAtColumn = ?, $dirtyColumn = 1 + WHERE profile_id = ? + """.trimIndent(), + parameters = 2, + ) { + bindLong(0, updatedAt) + bindString(1, profileId) + } + } + + @Test + fun `client modified timestamp bounds and adjacent dead letters cover every section`() = runTest { + data class TimestampCase(val suffix: String, val value: Long, val valid: Boolean) + + val cases = listOf( + TimestampCase("min", MIN_RFC3339_EPOCH_MILLIS, true), + TimestampCase("max", MAX_RFC3339_EPOCH_MILLIS, true), + TimestampCase("under", MIN_RFC3339_EPOCH_MILLIS - 1, false), + TimestampCase("over", MAX_RFC3339_EPOCH_MILLIS + 1, false), + ) + ProfilePreferenceSectionName.entries.forEach { section -> + cases.forEach { case -> + val profileId = "timestamp-${section.name.lowercase()}-${case.suffix}" + createProfile(profileId) + foundationRepository.insertDefaults(profileId) + forceDirtySectionUpdatedAt(profileId, section, case.value) + } + } + + val snapshot = repository.snapshotDirtySections() + ProfilePreferenceSectionName.entries.forEach { section -> + cases.forEach { case -> + val profileId = "timestamp-${section.name.lowercase()}-${case.suffix}" + val key = ProfilePreferenceSectionKey(profileId, section) + if (case.valid) { + assertEquals( + case.value, + snapshot.valid.single { it.key == key }.clientModifiedAtEpochMs, + ) + assertTrue(snapshot.unsyncable.none { it.key == key }) + } else { + assertTrue(snapshot.valid.none { it.key == key }) + assertEquals( + ProfilePreferenceSyncIssueReason.INVALID_CLIENT_MODIFIED_AT.name, + snapshot.unsyncable.single { it.key == key }.reason, + ) + assertTrue(snapshot.valid.any { + it.key.localProfileId == profileId && it.key.section != section + }) + } + } + } + } + + @Test + fun `invalid profile IDs dead letter every dirty section with fixed category`() = runTest { + val invalidProfileIds = listOf("", "SECRET_SENTINEL\u0000") + invalidProfileIds.forEach { profileId -> + createProfile(profileId) + foundationRepository.insertDefaults(profileId) + } + + val snapshot = repository.snapshotDirtySections() + invalidProfileIds.forEach { profileId -> + assertTrue(snapshot.valid.none { it.key.localProfileId == profileId }) + val issues = snapshot.unsyncable.filter { it.key.localProfileId == profileId } + assertEquals(ProfilePreferenceSectionName.entries.toSet(), issues.map { it.key.section }.toSet()) + assertEquals( + setOf(ProfilePreferenceSyncIssueReason.INVALID_PROFILE_ID.name), + issues.map { it.reason }.toSet(), + ) + assertTrue(issues.all { !it.reason.contains("SECRET_SENTINEL") }) + } + + createProfile("surrogate-template") + foundationRepository.insertDefaults("surrogate-template") + val surrogate = codec.encodeDirtyRow( + database.vitruvianDatabaseQueries.selectProfilePreferences("surrogate-template") + .executeAsOne() + .copy(profile_id = "bad\uD800"), + ) + assertTrue(surrogate.valid.isEmpty()) + assertEquals( + ProfilePreferenceSectionName.entries.toSet(), + surrogate.unsyncable.map { it.key.section }.toSet(), + ) + assertEquals( + setOf(ProfilePreferenceSyncIssueReason.INVALID_PROFILE_ID.name), + surrogate.unsyncable.map { it.reason }.toSet(), + ) + } + + private fun coreCanonical( + revision: Long, + bodyWeightKg: Double, + updatedAt: Long = 1_783_771_200_000L, + profileId: String = "profile-a", + ) = CanonicalProfilePreferenceSection( + key = ProfilePreferenceSectionKey(profileId, ProfilePreferenceSectionName.CORE), + documentVersion = 1, + serverRevision = revision, + serverUpdatedAtEpochMs = updatedAt, + payload = buildJsonObject { + put("bodyWeightKg", bodyWeightKg) + put("weightUnit", "KG") + put("weightIncrement", 0.5) + }, + ) + + @Test + fun `push canonical racing newer edit advances revision without clearing dirty`() = runTest { + createProfile("profile-a") + foundationRepository.insertDefaults("profile-a") + foundationRepository.updateCore("profile-a", CoreProfilePreferences(bodyWeightKg = 80f), now = 20) + val sent = repository.snapshotDirtySections().valid.single { + it.key.section == ProfilePreferenceSectionName.CORE + } + foundationRepository.updateCore("profile-a", CoreProfilePreferences(bodyWeightKg = 90f), now = 30) + + repository.applyPushOutcomes( + listOf( + ProfilePreferencePushOutcome( + key = sent.key, + sentLocalGeneration = sent.localGeneration, + serverRevision = 4, + canonical = coreCanonical(revision = 4, bodyWeightKg = 80.0), + rejectionReason = null, + ), + ), + ) + + val current = foundationRepository.get("profile-a").core + assertEquals(90f, current.value.bodyWeightKg) + assertEquals(4, current.metadata.serverRevision) + assertTrue(current.metadata.dirty) + assertTrue(current.metadata.localGeneration > sent.localGeneration) + } + + @Test + fun `matching generation conflict canonical replaces snapshot and clears dirty`() = runTest { + createProfile("profile-a") + foundationRepository.insertDefaults("profile-a") + foundationRepository.updateCore("profile-a", CoreProfilePreferences(bodyWeightKg = 80f), now = 20) + val sent = repository.snapshotDirtySections().valid.single { + it.key.section == ProfilePreferenceSectionName.CORE + } + + repository.applyPushOutcomes( + listOf( + ProfilePreferencePushOutcome( + key = sent.key, + sentLocalGeneration = sent.localGeneration, + serverRevision = 6, + canonical = coreCanonical(revision = 6, bodyWeightKg = 85.0), + rejectionReason = "REVISION_CONFLICT", + ), + ), + ) + + val current = foundationRepository.get("profile-a").core + assertEquals(85f, current.value.bodyWeightKg) + assertEquals(6, current.metadata.serverRevision) + assertFalse(current.metadata.dirty) + } + + @Test + fun `pull updates only clean nonnewer rows and never creates unknown profile`() = runTest { + createProfile("profile-a") + foundationRepository.insertDefaults("profile-a") + foundationRepository.updateCore("profile-a", CoreProfilePreferences(bodyWeightKg = 80f), now = 20) + val sent = repository.snapshotDirtySections().valid.single { + it.key.section == ProfilePreferenceSectionName.CORE + } + repository.applyPushOutcomes( + listOf( + ProfilePreferencePushOutcome( + key = sent.key, + sentLocalGeneration = sent.localGeneration, + serverRevision = 2, + canonical = coreCanonical(revision = 2, bodyWeightKg = 80.0, updatedAt = 100), + rejectionReason = null, + ), + ), + ) + + val report = repository.applyPulledSections( + listOf( + coreCanonical(revision = 3, bodyWeightKg = 83.0), + coreCanonical(revision = 9, bodyWeightKg = 99.0).copy( + key = ProfilePreferenceSectionKey("remote-only", ProfilePreferenceSectionName.CORE), + ), + ), + ) + + val current = foundationRepository.get("profile-a").core + assertEquals(83f, current.value.bodyWeightKg) + assertEquals(3, current.metadata.serverRevision) + assertFalse(current.metadata.dirty) + assertEquals(1, report.ignoredUnknownProfile) + assertProfileDoesNotExist("remote-only") + } + + @Test + fun `invalid unknown pull IDs are rejected before lookup while valid sibling applies`() = runTest { + val knownProfileId = "known-profile" + createProfile(knownProfileId) + foundationRepository.insertDefaults(knownProfileId) + acknowledgeAllDirty(knownProfileId, revision = 2, variant = 1) + + val report = repository.applyPulledSections( + listOf( + coreCanonical(3, 91.0, profileId = ""), + coreCanonical(3, 92.0, profileId = "SECRET_SENTINEL\u0000"), + coreCanonical(3, 93.0, profileId = "bad\uD800"), + coreCanonical(3, 83.0, profileId = knownProfileId), + ), + ) + + assertEquals(3, report.invalid) + assertEquals(0, report.ignoredUnknownProfile) + assertEquals(1, report.applied) + assertEquals(83f, foundationRepository.get(knownProfileId).core.value.bodyWeightKg) + } + + @Test + fun `dirty pull leaves payload revision timestamp generation and dirty flag unchanged`() = runTest { + createProfile("profile-a") + foundationRepository.insertDefaults("profile-a") + foundationRepository.updateCore("profile-a", CoreProfilePreferences(bodyWeightKg = 80f), now = 20) + val before = foundationRepository.get("profile-a").core + + repository.applyPulledSections(listOf(coreCanonical(revision = 3, bodyWeightKg = 83.0))) + + val after = foundationRepository.get("profile-a").core + assertEquals(before.value, after.value) + assertEquals(before.metadata.serverRevision, after.metadata.serverRevision) + assertEquals(before.metadata.updatedAt, after.metadata.updatedAt) + assertEquals(before.metadata.localGeneration, after.metadata.localGeneration) + assertTrue(after.metadata.dirty) + } + + @Test + fun `equal revision different payload repairs clean row`() = runTest { + createProfile("profile-a") + foundationRepository.insertDefaults("profile-a") + foundationRepository.updateCore("profile-a", CoreProfilePreferences(bodyWeightKg = 80f), now = 20) + val sent = repository.snapshotDirtySections().valid.single { + it.key.section == ProfilePreferenceSectionName.CORE + } + repository.applyPushOutcomes( + listOf( + ProfilePreferencePushOutcome( + key = sent.key, + sentLocalGeneration = sent.localGeneration, + serverRevision = 2, + canonical = coreCanonical(revision = 2, bodyWeightKg = 80.0, updatedAt = 100), + rejectionReason = null, + ), + ), + ) + + repository.applyPulledSections( + listOf(coreCanonical(revision = 2, bodyWeightKg = 83.0, updatedAt = 200)), + ) + + val repaired = foundationRepository.get("profile-a").core + assertEquals(83f, repaired.value.bodyWeightKg) + assertEquals(2, repaired.metadata.serverRevision) + assertEquals(200, repaired.metadata.updatedAt) + assertFalse(repaired.metadata.dirty) + } + + private fun canonical( + profileId: String, + section: ProfilePreferenceSectionName, + revision: Long, + variant: Int, + updatedAt: Long = 1_783_771_200_000L + variant, + ) = CanonicalProfilePreferenceSection( + key = ProfilePreferenceSectionKey(profileId, section), + documentVersion = 1, + serverRevision = revision, + serverUpdatedAtEpochMs = updatedAt, + payload = when (section) { + ProfilePreferenceSectionName.CORE -> buildJsonObject { + put("bodyWeightKg", 80 + variant) + put("weightUnit", "KG") + put("weightIncrement", 0.5) + } + ProfilePreferenceSectionName.RACK -> buildJsonObject { + put("version", 1) + putJsonArray("items") { + add(buildJsonObject { + put("id", "rack-$variant") + put("name", "Rack $variant") + put("category", "OTHER") + put("weightKg", variant) + put("behavior", "ADDED_RESISTANCE") + put("enabled", true) + put("sortOrder", variant) + put("createdAt", variant.toLong()) + put("updatedAt", variant.toLong()) + }) + } + } + ProfilePreferenceSectionName.WORKOUT -> buildJsonObject { + put("version", 1) + put("stopAtTop", variant % 2 == 1) + } + ProfilePreferenceSectionName.LED -> buildJsonObject { + put("ledColorSchemeId", variant) + putJsonObject("preferences") { + put("version", 1) + put("discoModeUnlocked", variant % 2 == 1) + } + } + ProfilePreferenceSectionName.VBT -> buildJsonObject { + put("vbtEnabled", variant % 2 == 0) + putJsonObject("preferences") { + put("version", 1) + put("velocityLossThresholdPercent", 20 + variant) + } + } + }, + ) + + private fun assertVariant(preferences: UserProfilePreferences, variant: Int) { + assertEquals((80 + variant).toFloat(), preferences.core.value.bodyWeightKg) + assertEquals(WeightUnit.KG, preferences.core.value.weightUnit) + assertEquals(0.5f, preferences.core.value.weightIncrement) + assertEquals("rack-$variant", preferences.rack.value.items.single().id) + assertEquals(variant.toFloat(), preferences.rack.value.items.single().weightKg) + assertEquals(variant % 2 == 1, preferences.workout.value.stopAtTop) + assertEquals(variant, preferences.led.value.colorScheme) + assertEquals(variant % 2 == 1, preferences.led.value.discoModeUnlocked) + assertEquals(variant % 2 == 0, preferences.vbt.value.enabled) + assertEquals(20 + variant, preferences.vbt.value.velocityLossThresholdPercent) + } + + private fun allMetadata(preferences: UserProfilePreferences) = listOf( + preferences.core.metadata, + preferences.rack.metadata, + preferences.workout.metadata, + preferences.led.metadata, + preferences.vbt.metadata, + ) + + private suspend fun acknowledgeAllDirty(profileId: String, revision: Long, variant: Int) { + val sent = repository.snapshotDirtySections().valid + .filter { it.key.localProfileId == profileId } + .associateBy { it.key.section } + repository.applyPushOutcomes( + ProfilePreferenceSectionName.entries.map { section -> + val snapshot = sent.getValue(section) + ProfilePreferencePushOutcome( + key = snapshot.key, + sentLocalGeneration = snapshot.localGeneration, + serverRevision = revision, + canonical = canonical(profileId, section, revision, variant), + rejectionReason = null, + ) + }, + ) + } + + @Test + fun `matching generation push persists all five sections and row owned columns`() = runTest { + createProfile("push-all") + foundationRepository.insertDefaults("push-all") + + acknowledgeAllDirty("push-all", revision = 2, variant = 1) + + val preferences = foundationRepository.get("push-all") + assertVariant(preferences, variant = 1) + allMetadata(preferences).forEach { metadata -> + assertEquals(2, metadata.serverRevision) + assertFalse(metadata.dirty) + } + val row = database.vitruvianDatabaseQueries + .selectProfilePreferenceSyncRow("push-all") + .executeAsOne() + assertEquals(1L, row.led_color_scheme_id) + assertFalse(row.led_preferences_json.contains("colorScheme")) + assertEquals(0L, row.vbt_enabled) + assertFalse(row.vbt_preferences_json.contains("enabled")) + } + + @Test + fun `newer local generations preserve all five values while advancing revisions`() = runTest { + createProfile("race-all") + foundationRepository.insertDefaults("race-all") + val sent = repository.snapshotDirtySections().valid + .filter { it.key.localProfileId == "race-all" } + .associateBy { it.key.section } + foundationRepository.updateCore( + "race-all", + CoreProfilePreferences(95f, WeightUnit.KG, 1f), + now = 30, + ) + foundationRepository.updateRack( + "race-all", + RackPreferences(items = listOf(RackItem(id = "local", name = "Local", weightKg = 9f))), + now = 30, + ) + foundationRepository.updateWorkout( + "race-all", + WorkoutPreferences(stopAtTop = false, beepsEnabled = false), + now = 30, + ) + foundationRepository.updateLed( + "race-all", + LedPreferences(colorScheme = 9, discoModeUnlocked = false), + now = 30, + ) + foundationRepository.updateVbt( + "race-all", + VbtPreferences(enabled = true, velocityLossThresholdPercent = 45), + now = 30, + ) + + val report = repository.applyPushOutcomes( + ProfilePreferenceSectionName.entries.map { section -> + val snapshot = sent.getValue(section) + ProfilePreferencePushOutcome( + key = snapshot.key, + sentLocalGeneration = snapshot.localGeneration, + serverRevision = 4, + canonical = canonical("race-all", section, revision = 4, variant = 1), + rejectionReason = null, + ) + }, + ) + + val current = foundationRepository.get("race-all") + assertEquals(95f, current.core.value.bodyWeightKg) + assertEquals("local", current.rack.value.items.single().id) + assertFalse(current.workout.value.beepsEnabled) + assertEquals(9, current.led.value.colorScheme) + assertEquals(45, current.vbt.value.velocityLossThresholdPercent) + allMetadata(current).forEach { metadata -> + assertEquals(4, metadata.serverRevision) + assertTrue(metadata.dirty) + } + assertEquals(5, report.preservedNewerLocal) + } + + @Test + fun `clean pull applies all five sections and rejects every lower revision`() = runTest { + createProfile("pull-all") + foundationRepository.insertDefaults("pull-all") + acknowledgeAllDirty("pull-all", revision = 2, variant = 1) + + val applied = repository.applyPulledSections( + ProfilePreferenceSectionName.entries.map { section -> + canonical("pull-all", section, revision = 3, variant = 2) + }, + ) + val afterHigher = foundationRepository.get("pull-all") + assertEquals(5, applied.applied) + assertVariant(afterHigher, variant = 2) + allMetadata(afterHigher).forEach { metadata -> + assertEquals(3, metadata.serverRevision) + assertFalse(metadata.dirty) + } + + val lower = repository.applyPulledSections( + ProfilePreferenceSectionName.entries.map { section -> + canonical("pull-all", section, revision = 2, variant = 3) + }, + ) + assertEquals(0, lower.applied) + assertEquals(afterHigher, foundationRepository.get("pull-all")) + } + + @Test + fun `canonical null identity mismatch and revision mismatch remain dirty`() = runTest { + createProfile("invalid-outcome") + foundationRepository.insertDefaults("invalid-outcome") + foundationRepository.updateCore( + "invalid-outcome", + CoreProfilePreferences(bodyWeightKg = 80f), + now = 20, + ) + val sent = repository.snapshotDirtySections().valid.single { + it.key.localProfileId == "invalid-outcome" && + it.key.section == ProfilePreferenceSectionName.CORE + } + + val noCanonical = repository.applyPushOutcomes( + listOf(ProfilePreferencePushOutcome(sent.key, sent.localGeneration, 2, null, null)), + ) + assertEquals(0, noCanonical.applied) + assertTrue(foundationRepository.get("invalid-outcome").core.metadata.dirty) + + val wrongKey = repository.applyPushOutcomes( + listOf( + ProfilePreferencePushOutcome( + sent.key, + sent.localGeneration, + 2, + canonical("other-profile", ProfilePreferenceSectionName.CORE, 2, 1), + null, + ), + ), + ) + val wrongRevision = repository.applyPushOutcomes( + listOf( + ProfilePreferencePushOutcome( + sent.key, + sent.localGeneration, + 3, + canonical("invalid-outcome", ProfilePreferenceSectionName.CORE, 2, 1), + null, + ), + ), + ) + assertEquals(1, wrongKey.invalid) + assertEquals(1, wrongRevision.invalid) + assertTrue(foundationRepository.get("invalid-outcome").core.metadata.dirty) + } + + @Test + fun `canonical revision bounds and malformed payload fail closed with category only reasons`() { + val max = coreCanonical(MAX_EXACT_JSON_INTEGER, 80.0) + assertIs(codec.decodeCanonical(max)) + listOf(-1L, MAX_EXACT_JSON_INTEGER + 1).forEach { revision -> + val invalid = assertIs( + codec.decodeCanonical(coreCanonical(revision, 80.0)), + ) + assertEquals(ProfilePreferenceSyncIssueReason.INVALID_CANONICAL_REVISION, invalid.reason) + } + val sentinel = "SECRET_SENTINEL" + val malformed = canonical("profile-a", ProfilePreferenceSectionName.WORKOUT, 3, 1) + .copy(payload = buildJsonObject { + put("version", 1) + put("repCountTiming", sentinel) + }) + val invalid = assertIs( + codec.decodeCanonical(malformed), + ) + assertEquals(ProfilePreferenceSyncIssueReason.INVALID_CANONICAL_PAYLOAD, invalid.reason) + assertFalse(invalid.reason.name.contains(sentinel)) + } + + @Test + fun `canonical profile ID and timestamp boundaries fail closed before persistence`() = runTest { + listOf("", "SECRET_SENTINEL\u0000", "bad\uD800").forEach { profileId -> + val invalid = assertIs( + codec.decodeCanonical(coreCanonical(2, 80.0, profileId = profileId)), + ) + assertEquals(ProfilePreferenceSyncIssueReason.INVALID_PROFILE_ID, invalid.reason) + assertFalse(invalid.reason.name.contains("SECRET_SENTINEL")) + } + listOf(MIN_RFC3339_EPOCH_MILLIS, MAX_RFC3339_EPOCH_MILLIS).forEach { updatedAt -> + assertIs( + codec.decodeCanonical(coreCanonical(2, 80.0, updatedAt = updatedAt)), + ) + } + listOf( + MIN_RFC3339_EPOCH_MILLIS - 1, + MAX_RFC3339_EPOCH_MILLIS + 1, + ).forEach { updatedAt -> + val invalid = assertIs( + codec.decodeCanonical(coreCanonical(2, 80.0, updatedAt = updatedAt)), + ) + assertEquals(ProfilePreferenceSyncIssueReason.INVALID_CANONICAL_TIMESTAMP, invalid.reason) + } + + createProfile("profile-a") + foundationRepository.insertDefaults("profile-a") + acknowledgeAllDirty("profile-a", revision = 2, variant = 1) + val before = foundationRepository.get("profile-a") + val report = repository.applyPulledSections( + listOf( + coreCanonical( + revision = 3, + bodyWeightKg = 90.0, + updatedAt = MAX_RFC3339_EPOCH_MILLIS + 1, + ), + ), + ) + assertEquals(1, report.invalid) + assertEquals(before, foundationRepository.get("profile-a")) + } + + private fun rackCanonicalWithTimestamps( + createdAt: Long, + updatedAt: Long, + revision: Long = 3, + profileId: String = "profile-a", + ) = CanonicalProfilePreferenceSection( + key = ProfilePreferenceSectionKey(profileId, ProfilePreferenceSectionName.RACK), + documentVersion = 1, + serverRevision = revision, + serverUpdatedAtEpochMs = 1_783_771_200_000L, + payload = buildJsonObject { + put("version", 1) + putJsonArray("items") { + add(buildJsonObject { + put("id", "rack-boundary") + put("name", "Rack") + put("weightKg", 20) + put("createdAt", createdAt) + put("updatedAt", updatedAt) + }) + } + }, + ) + + @Test + fun `rack canonical timestamp exact integer bounds pass and adjacent values do not mutate`() = runTest { + assertIs( + codec.decodeCanonical( + rackCanonicalWithTimestamps(MIN_EXACT_JSON_INTEGER, MAX_EXACT_JSON_INTEGER), + ), + ) + listOf( + rackCanonicalWithTimestamps(MAX_EXACT_JSON_INTEGER + 1, 0), + rackCanonicalWithTimestamps(0, MIN_EXACT_JSON_INTEGER - 1), + ).forEach { canonical -> + val invalid = assertIs( + codec.decodeCanonical(canonical), + ) + assertEquals(ProfilePreferenceSyncIssueReason.UNREPRESENTABLE_JSON_INTEGER, invalid.reason) + } + + createProfile("profile-a") + foundationRepository.insertDefaults("profile-a") + acknowledgeAllDirty("profile-a", revision = 2, variant = 1) + val before = foundationRepository.get("profile-a").rack + val report = repository.applyPulledSections( + listOf(rackCanonicalWithTimestamps(MAX_EXACT_JSON_INTEGER + 1, 0)), + ) + assertEquals(1, report.invalid) + assertEquals(before, foundationRepository.get("profile-a").rack) + } + + @Test + fun `canonical divergence event never exposes user controlled profile ID`() = runTest { + val profileId = "SECRET_SENTINEL-profile" + createProfile(profileId) + foundationRepository.insertDefaults(profileId) + acknowledgeAllDirty(profileId, revision = 2, variant = 1) + val observedSections = mutableListOf() + val loggingRepository = SqlDelightProfilePreferenceSyncRepository( + database, + codec, + ) { section -> observedSections += section } + + loggingRepository.applyPulledSections( + listOf(coreCanonical(2, 99.0, profileId = profileId)), + ) + + assertEquals(listOf(ProfilePreferenceSectionName.CORE), observedSections) + assertFalse(observedSections.joinToString().contains("SECRET_SENTINEL")) + } + + @Test + fun `malformed pulled canonical is isolated while valid sibling applies`() = runTest { + createProfile("malformed-pull") + foundationRepository.insertDefaults("malformed-pull") + acknowledgeAllDirty("malformed-pull", revision = 2, variant = 1) + val malformed = canonical( + "malformed-pull", + ProfilePreferenceSectionName.WORKOUT, + revision = 3, + variant = 2, + ).copy(payload = buildJsonObject { + put("version", 1) + put("repCountTiming", "SECRET_SENTINEL") + }) + + val report = repository.applyPulledSections( + listOf( + malformed, + canonical("malformed-pull", ProfilePreferenceSectionName.CORE, 3, 2), + ), + ) + + val current = foundationRepository.get("malformed-pull") + assertEquals(1, report.invalid) + assertEquals(1, report.applied) + assertTrue(current.workout.value.stopAtTop) + assertEquals(82f, current.core.value.bodyWeightKg) + } + + @Test + fun `matching generation requires dirty state and nonregressing revision`() = runTest { + createProfile("stale-push") + foundationRepository.insertDefaults("stale-push") + val sent = repository.snapshotDirtySections().valid.single { + it.key.localProfileId == "stale-push" && + it.key.section == ProfilePreferenceSectionName.CORE + } + repository.applyPushOutcomes( + listOf( + ProfilePreferencePushOutcome( + sent.key, + sent.localGeneration, + 6, + canonical("stale-push", ProfilePreferenceSectionName.CORE, 6, 6), + null, + ), + ), + ) + val stale = ProfilePreferencePushOutcome( + sent.key, + sent.localGeneration, + 5, + canonical("stale-push", ProfilePreferenceSectionName.CORE, 5, 5), + null, + ) + assertEquals(0, repository.applyPushOutcomes(listOf(stale)).applied) + + driver.execute( + null, + "UPDATE UserProfilePreferences SET core_dirty = 1 WHERE profile_id = ?", + 1, + ) { bindString(0, "stale-push") } + assertEquals(0, repository.applyPushOutcomes(listOf(stale)).applied) + val current = foundationRepository.get("stale-push").core + assertEquals(86f, current.value.bodyWeightKg) + assertEquals(6, current.metadata.serverRevision) + assertTrue(current.metadata.dirty) + } + + @Test + fun `lost acknowledgement then later edit retains approved matching generation server wins policy`() = + runTest { + createProfile("lost-ack-edit") + foundationRepository.insertDefaults("lost-ack-edit") + foundationRepository.updateCore( + "lost-ack-edit", + CoreProfilePreferences(bodyWeightKg = 80f), + now = 20, + ) + val committedButUnacknowledged = repository.snapshotDirtySections().valid.single { + it.key.localProfileId == "lost-ack-edit" && + it.key.section == ProfilePreferenceSectionName.CORE + } + + // Simulate the server committing generation 1 at revision 1 and the HTTP response + // being lost: do not apply an outcome locally before the user edits again. + foundationRepository.updateCore( + "lost-ack-edit", + CoreProfilePreferences(bodyWeightKg = 90f), + now = 30, + ) + val retry = repository.snapshotDirtySections().valid.single { + it.key == committedButUnacknowledged.key + } + assertTrue(retry.localGeneration > committedButUnacknowledged.localGeneration) + assertEquals(0L, retry.baseRevision) + + repository.applyPushOutcomes( + listOf( + ProfilePreferencePushOutcome( + key = retry.key, + sentLocalGeneration = retry.localGeneration, + serverRevision = 1, + canonical = coreCanonical( + revision = 1, + bodyWeightKg = 80.0, + profileId = "lost-ack-edit", + ), + rejectionReason = "REVISION_CONFLICT", + ), + ), + ) + + val current = foundationRepository.get("lost-ack-edit").core + assertEquals(80f, current.value.bodyWeightKg) + assertEquals(1L, current.metadata.serverRevision) + assertFalse(current.metadata.dirty) + } + + @Test + fun `semantic numeric equality does not become equal revision divergence`() = runTest { + createProfile("numeric-equality") + foundationRepository.insertDefaults("numeric-equality") + val integerToken = coreCanonical(0, 80.0, profileId = "numeric-equality").copy( + payload = PortalWireJson.parseToJsonElement( + """{"bodyWeightKg":80,"weightUnit":"KG","weightIncrement":0.5}""", + ).jsonObject, + ) + driver.execute( + null, + """ + UPDATE UserProfilePreferences + SET body_weight_kg = 80, weight_unit = 'KG', weight_increment = 0.5, + core_dirty = 0 + WHERE profile_id = ? + """.trimIndent(), + 1, + ) { bindString(0, "numeric-equality") } + val normalizedRow = database.vitruvianDatabaseQueries + .selectProfilePreferenceSyncRow("numeric-equality") + .executeAsOne() + + assertFalse(codec.hasCanonicalDivergence(normalizedRow, integerToken)) + assertTrue( + codec.hasCanonicalDivergence( + normalizedRow, + coreCanonical(0, 83.0, profileId = "numeric-equality"), + ), + ) + } + + private companion object { + const val MAX_EXACT_JSON_INTEGER = 9_007_199_254_740_991L + const val MIN_EXACT_JSON_INTEGER = -9_007_199_254_740_991L + const val MIN_RFC3339_EPOCH_MILLIS = -62_135_596_800_000L + const val MAX_RFC3339_EPOCH_MILLIS = 253_402_300_799_999L + } +} diff --git a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/e2e/WorkoutFlowE2ETest.kt b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/e2e/WorkoutFlowE2ETest.kt index ba6506b17..95b4950f4 100644 --- a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/e2e/WorkoutFlowE2ETest.kt +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/e2e/WorkoutFlowE2ETest.kt @@ -1,6 +1,7 @@ package com.devil.phoenixproject.e2e -import com.devil.phoenixproject.data.repository.SettingsEquipmentRackRepository +import androidx.lifecycle.viewModelScope +import com.devil.phoenixproject.data.repository.ProfileEquipmentRackRepository import com.devil.phoenixproject.domain.model.ProgramMode import com.devil.phoenixproject.domain.usecase.ApplyEquipmentRackLoadUseCase import com.devil.phoenixproject.domain.usecase.CountVelocityOneRepMaxImprovementsUseCase @@ -23,11 +24,13 @@ import com.devil.phoenixproject.testutil.FakeRepMetricRepository import com.devil.phoenixproject.testutil.FakeTrainingCycleRepository import com.devil.phoenixproject.testutil.FakeVelocityOneRepMaxRepository import com.devil.phoenixproject.testutil.FakeWorkoutRepository +import com.devil.phoenixproject.testutil.FakeUserProfileRepository import com.devil.phoenixproject.testutil.TestCoroutineRule -import com.russhwolf.settings.MapSettings import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest +import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test @@ -55,6 +58,8 @@ class WorkoutFlowE2ETest { private lateinit var repCounter: RepCounterFromMachine private lateinit var resolveWeightsUseCase: ResolveRoutineWeightsUseCase private lateinit var robot: WorkoutRobot + private lateinit var fakeUserProfileRepository: FakeUserProfileRepository + private lateinit var profileEquipmentRackRepository: ProfileEquipmentRackRepository @Before fun setup() { @@ -69,6 +74,11 @@ class WorkoutFlowE2ETest { fakeRepMetricRepository = FakeRepMetricRepository() repCounter = RepCounterFromMachine() resolveWeightsUseCase = ResolveRoutineWeightsUseCase(fakePersonalRecordRepository, fakeExerciseRepository, FakeVelocityOneRepMaxRepository()) + fakeUserProfileRepository = FakeUserProfileRepository().apply { setActiveProfileForTest() } + profileEquipmentRackRepository = ProfileEquipmentRackRepository( + fakeUserProfileRepository, + kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.SupervisorJob() + kotlinx.coroutines.Dispatchers.Main), + ) viewModel = MainViewModel( bleRepository = fakeBleRepository, @@ -84,10 +94,10 @@ class WorkoutFlowE2ETest { biomechanicsRepository = FakeBiomechanicsRepository(), resolveWeightsUseCase = resolveWeightsUseCase, recommendWeightAdjustmentUseCase = RecommendWeightAdjustmentUseCase(), - equipmentRackRepository = SettingsEquipmentRackRepository(MapSettings()), + equipmentRackRepository = profileEquipmentRackRepository, applyEquipmentRackLoadUseCase = ApplyEquipmentRackLoadUseCase(), dataBackupManager = FakeDataBackupManager(), - userProfileRepository = com.devil.phoenixproject.testutil.FakeUserProfileRepository(), + userProfileRepository = fakeUserProfileRepository, workoutServiceController = NoOpWorkoutServiceController, computeVelocityOneRepMaxUseCase = com.devil.phoenixproject.domain.usecase.ComputeVelocityOneRepMaxUseCase( workoutPoints = { _, _, _ -> emptyList() }, @@ -123,6 +133,12 @@ class WorkoutFlowE2ETest { robot = WorkoutRobot(viewModel, fakeBleRepository) } + @After + fun tearDown() { + viewModel.viewModelScope.cancel() + profileEquipmentRackRepository.close() + } + // ========== Connection Flow Tests ========== @Test diff --git a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/manager/BleConnectionManagerTest.kt b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/manager/BleConnectionManagerTest.kt index 7ce0236d9..4ca04425f 100644 --- a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/manager/BleConnectionManagerTest.kt +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/manager/BleConnectionManagerTest.kt @@ -1,8 +1,10 @@ package com.devil.phoenixproject.presentation.manager import com.devil.phoenixproject.domain.model.ConnectionState +import com.devil.phoenixproject.domain.model.LedPreferences import com.devil.phoenixproject.testutil.FakeBleRepository import com.devil.phoenixproject.testutil.FakePreferencesManager +import com.devil.phoenixproject.testutil.FakeUserProfileRepository import com.devil.phoenixproject.testutil.TestCoroutineRule import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -27,11 +29,48 @@ class BleConnectionManagerTest { private lateinit var fakeBleRepository: FakeBleRepository private lateinit var fakePreferencesManager: FakePreferencesManager + private lateinit var fakeProfileRepository: FakeUserProfileRepository @Before fun setup() { fakeBleRepository = FakeBleRepository() fakePreferencesManager = FakePreferencesManager() + fakeProfileRepository = FakeUserProfileRepository().apply { setActiveProfileForTest() } + } + + @Test + fun `connected BLE follows active profile LED scheme`() = runTest { + val managerScope = CoroutineScope(coroutineContext + SupervisorJob()) + try { + val profiles = FakeUserProfileRepository().apply { + setActiveProfileForTest(id = "profile-a") + updateLed("profile-a", LedPreferences(colorScheme = 2)) + } + val settingsManager = SettingsManager(fakePreferencesManager, profiles, managerScope) + BleConnectionManager( + fakeBleRepository, + settingsManager, + FakeWorkoutStateProvider(active = false), + MutableSharedFlow(), + managerScope, + ) + advanceUntilIdle() + + fakeBleRepository.simulateConnect("Vee_Test") + advanceUntilIdle() + profiles.setActiveProfileForTest(id = "profile-b") + profiles.updateLed("profile-b", LedPreferences(colorScheme = 7)) + advanceUntilIdle() + + assertEquals(7, fakeBleRepository.colorSchemeCommands.last()) + fakeBleRepository.simulateDisconnect() + advanceUntilIdle() + fakeBleRepository.simulateConnect("Vee_Test") + advanceUntilIdle() + assertEquals(listOf(7, 7), fakeBleRepository.colorSchemeCommands.takeLast(2)) + } finally { + managerScope.cancel() + } } @Test @@ -40,7 +79,7 @@ class BleConnectionManagerTest { try { val workoutStateProvider = FakeWorkoutStateProvider(active = true) val settingsManager = - SettingsManager(fakePreferencesManager, fakeBleRepository, managerScope) + SettingsManager(fakePreferencesManager, fakeProfileRepository, managerScope) val manager = BleConnectionManager( fakeBleRepository, @@ -72,7 +111,7 @@ class BleConnectionManagerTest { try { val workoutStateProvider = FakeWorkoutStateProvider(active = false) val settingsManager = - SettingsManager(fakePreferencesManager, fakeBleRepository, managerScope) + SettingsManager(fakePreferencesManager, fakeProfileRepository, managerScope) val manager = BleConnectionManager( fakeBleRepository, @@ -100,7 +139,7 @@ class BleConnectionManagerTest { try { val workoutStateProvider = FakeWorkoutStateProvider(active = false) val settingsManager = - SettingsManager(fakePreferencesManager, fakeBleRepository, managerScope) + SettingsManager(fakePreferencesManager, fakeProfileRepository, managerScope) val manager = BleConnectionManager( fakeBleRepository, @@ -135,7 +174,7 @@ class BleConnectionManagerTest { try { val workoutStateProvider = FakeWorkoutStateProvider(active = false) val settingsManager = - SettingsManager(fakePreferencesManager, fakeBleRepository, managerScope) + SettingsManager(fakePreferencesManager, fakeProfileRepository, managerScope) val manager = BleConnectionManager( fakeBleRepository, @@ -168,7 +207,7 @@ class BleConnectionManagerTest { try { val workoutStateProvider = FakeWorkoutStateProvider(active = false) val settingsManager = - SettingsManager(fakePreferencesManager, fakeBleRepository, managerScope) + SettingsManager(fakePreferencesManager, fakeProfileRepository, managerScope) val manager = BleConnectionManager( fakeBleRepository, diff --git a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/manager/SettingsManagerTest.kt b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/manager/SettingsManagerTest.kt index 160a2291d..0d84bd0e6 100644 --- a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/manager/SettingsManagerTest.kt +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/manager/SettingsManagerTest.kt @@ -3,11 +3,16 @@ package com.devil.phoenixproject.presentation.manager import com.devil.phoenixproject.domain.model.ScalingBasis import com.devil.phoenixproject.domain.model.UserPreferences import com.devil.phoenixproject.domain.model.WeightUnit +import com.devil.phoenixproject.data.preferences.SettingsPreferencesManager +import com.devil.phoenixproject.data.repository.ActiveProfileContext import com.devil.phoenixproject.testutil.FakeBleRepository import com.devil.phoenixproject.testutil.FakePreferencesManager +import com.devil.phoenixproject.testutil.FakeUserProfileRepository import com.devil.phoenixproject.testutil.TestCoroutineRule +import com.russhwolf.settings.MapSettings import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertIs import kotlin.test.assertTrue import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -27,26 +32,28 @@ class SettingsManagerTest { private lateinit var fakePreferencesManager: FakePreferencesManager private lateinit var fakeBleRepository: FakeBleRepository + private lateinit var fakeProfileRepository: FakeUserProfileRepository @Before fun setup() { fakePreferencesManager = FakePreferencesManager() fakeBleRepository = FakeBleRepository() + fakeProfileRepository = FakeUserProfileRepository().apply { setActiveProfileForTest() } } @Test fun `autoplayEnabled derives from summary countdown seconds`() = runTest { val managerScope = CoroutineScope(coroutineContext + SupervisorJob()) try { - val manager = SettingsManager(fakePreferencesManager, fakeBleRepository, managerScope) + val manager = SettingsManager(fakePreferencesManager, fakeProfileRepository, managerScope) assertTrue(manager.autoplayEnabled.value) - fakePreferencesManager.setPreferences(UserPreferences(summaryCountdownSeconds = 0)) + manager.setSummaryCountdownSeconds(0) advanceUntilIdle() assertFalse(manager.autoplayEnabled.value) - fakePreferencesManager.setPreferences(UserPreferences(summaryCountdownSeconds = -1)) + manager.setSummaryCountdownSeconds(-1) advanceUntilIdle() assertTrue(manager.autoplayEnabled.value) } finally { @@ -58,12 +65,17 @@ class SettingsManagerTest { fun `setWeightUnit updates preference-backed flows`() = runTest { val managerScope = CoroutineScope(coroutineContext + SupervisorJob()) try { - val manager = SettingsManager(fakePreferencesManager, fakeBleRepository, managerScope) + val manager = SettingsManager(fakePreferencesManager, fakeProfileRepository, managerScope) manager.setWeightUnit(WeightUnit.KG) advanceUntilIdle() - assertEquals(WeightUnit.KG, fakePreferencesManager.preferencesFlow.value.weightUnit) + assertEquals( + WeightUnit.KG, + assertIs(fakeProfileRepository.activeProfileContext.value) + .preferences.core.value.weightUnit, + ) + assertEquals(WeightUnit.LB, fakePreferencesManager.preferencesFlow.value.weightUnit) assertEquals(WeightUnit.KG, manager.userPreferences.value.weightUnit) } finally { managerScope.cancel() @@ -74,14 +86,18 @@ class SettingsManagerTest { fun `setDefaultScalingBasis persists to preference-backed flow`() = runTest { val managerScope = CoroutineScope(coroutineContext + SupervisorJob()) try { - val manager = SettingsManager(fakePreferencesManager, fakeBleRepository, managerScope) + val manager = SettingsManager(fakePreferencesManager, fakeProfileRepository, managerScope) assertEquals(ScalingBasis.MAX_WEIGHT_PR, manager.defaultScalingBasis.value) manager.setDefaultScalingBasis(ScalingBasis.ESTIMATED_1RM) advanceUntilIdle() - assertEquals(ScalingBasis.ESTIMATED_1RM, fakePreferencesManager.preferencesFlow.value.defaultScalingBasis) + assertEquals( + ScalingBasis.ESTIMATED_1RM, + assertIs(fakeProfileRepository.activeProfileContext.value) + .preferences.vbt.value.defaultScalingBasis, + ) assertEquals(ScalingBasis.ESTIMATED_1RM, manager.defaultScalingBasis.value) } finally { managerScope.cancel() @@ -92,7 +108,7 @@ class SettingsManagerTest { fun `routine exercise percent defaults are off and eighty percent`() = runTest { val managerScope = CoroutineScope(coroutineContext + SupervisorJob()) try { - val manager = SettingsManager(fakePreferencesManager, fakeBleRepository, managerScope) + val manager = SettingsManager(fakePreferencesManager, fakeProfileRepository, managerScope) assertFalse(manager.defaultRoutineExerciseUsePercentOfPR.value) assertEquals(80, manager.defaultRoutineExerciseWeightPercentOfPR.value) @@ -105,21 +121,25 @@ class SettingsManagerTest { fun `set routine exercise percent preferences persist and coerce range`() = runTest { val managerScope = CoroutineScope(coroutineContext + SupervisorJob()) try { - val manager = SettingsManager(fakePreferencesManager, fakeBleRepository, managerScope) + val manager = SettingsManager(fakePreferencesManager, fakeProfileRepository, managerScope) manager.setDefaultRoutineExerciseUsePercentOfPR(true) manager.setDefaultRoutineExerciseWeightPercentOfPR(135) advanceUntilIdle() - assertTrue(fakePreferencesManager.preferencesFlow.value.defaultRoutineExerciseUsePercentOfPR) - assertEquals(120, fakePreferencesManager.preferencesFlow.value.defaultRoutineExerciseWeightPercentOfPR) + var workout = assertIs(fakeProfileRepository.activeProfileContext.value) + .preferences.workout.value + assertTrue(workout.defaultRoutineExerciseUsePercentOfPR) + assertEquals(120, workout.defaultRoutineExerciseWeightPercentOfPR) assertTrue(manager.defaultRoutineExerciseUsePercentOfPR.value) assertEquals(120, manager.defaultRoutineExerciseWeightPercentOfPR.value) manager.setDefaultRoutineExerciseWeightPercentOfPR(20) advanceUntilIdle() - assertEquals(50, fakePreferencesManager.preferencesFlow.value.defaultRoutineExerciseWeightPercentOfPR) + workout = assertIs(fakeProfileRepository.activeProfileContext.value) + .preferences.workout.value + assertEquals(50, workout.defaultRoutineExerciseWeightPercentOfPR) assertEquals(50, manager.defaultRoutineExerciseWeightPercentOfPR.value) } finally { managerScope.cancel() @@ -130,7 +150,7 @@ class SettingsManagerTest { fun `setVelocityOneRepMaxBackfillDone persists to preference-backed flow`() = runTest { val managerScope = CoroutineScope(coroutineContext + SupervisorJob()) try { - val manager = SettingsManager(fakePreferencesManager, fakeBleRepository, managerScope) + val manager = SettingsManager(fakePreferencesManager, fakeProfileRepository, managerScope) assertFalse(manager.velocityOneRepMaxBackfillDone.value) @@ -148,7 +168,7 @@ class SettingsManagerTest { fun `weight conversion and formatting preserves legacy behavior`() = runTest { val managerScope = CoroutineScope(coroutineContext + SupervisorJob()) try { - val manager = SettingsManager(fakePreferencesManager, fakeBleRepository, managerScope) + val manager = SettingsManager(fakePreferencesManager, fakeProfileRepository, managerScope) assertEquals(22.0462f, manager.kgToDisplay(10f, WeightUnit.LB), 0.0001f) assertEquals(10f, manager.displayToKg(22.0462f, WeightUnit.LB), 0.001f) @@ -158,4 +178,276 @@ class SettingsManagerTest { managerScope.cancel() } } + + @Test + fun `initial compatibility value overlays an already Ready profile synchronously`() = runTest { + fakePreferencesManager.setPreferences( + UserPreferences( + bodyWeightKg = 140f, + summaryCountdownSeconds = 25, + velocityLossThresholdPercent = 11, + ), + ) + var ready = assertIs(fakeProfileRepository.activeProfileContext.value) + fakeProfileRepository.updateCore( + ready.profile.id, + ready.preferences.core.value.copy( + weightUnit = WeightUnit.LB, + bodyWeightKg = 83f, + ), + ) + ready = assertIs(fakeProfileRepository.activeProfileContext.value) + fakeProfileRepository.updateWorkout( + ready.profile.id, + ready.preferences.workout.value.copy( + summaryCountdownSeconds = 0, + gamificationEnabled = false, + ), + ) + ready = assertIs(fakeProfileRepository.activeProfileContext.value) + fakeProfileRepository.updateVbt( + ready.profile.id, + ready.preferences.vbt.value.copy(velocityLossThresholdPercent = 37), + ) + val managerScope = CoroutineScope(coroutineContext + SupervisorJob()) + try { + val manager = SettingsManager(fakePreferencesManager, fakeProfileRepository, managerScope) + + assertEquals(83f, manager.userPreferences.value.bodyWeightKg) + assertEquals(0, manager.userPreferences.value.summaryCountdownSeconds) + assertEquals(37, manager.userPreferences.value.velocityLossThresholdPercent) + assertEquals(WeightUnit.LB, manager.weightUnit.value) + assertFalse(manager.gamificationEnabled.value) + assertFalse(manager.autoplayEnabled.value) + } finally { + managerScope.cancel() + } + } + + @Test + fun `profile setter writes repository and leaves legacy store untouched`() = runTest { + val legacySettings = MapSettings().apply { + putFloat("body_weight_kg", 70f) + } + val globalPreferences = SettingsPreferencesManager(legacySettings) + val profiles = FakeUserProfileRepository().apply { + setActiveProfileForTest(id = "profile-a") + } + val managerScope = CoroutineScope(coroutineContext + SupervisorJob()) + try { + val manager = SettingsManager(globalPreferences, profiles, managerScope) + + manager.setBodyWeightKg(82f) + advanceUntilIdle() + + val ready = assertIs(profiles.activeProfileContext.value) + assertEquals(82f, ready.preferences.core.value.bodyWeightKg) + assertEquals(70f, legacySettings.getFloat("body_weight_kg", 0f)) + } finally { + managerScope.cancel() + } + } + + @Test + fun `queued same-section setters preserve sibling changes`() = runTest { + val managerScope = CoroutineScope(coroutineContext + SupervisorJob()) + try { + val manager = SettingsManager(fakePreferencesManager, fakeProfileRepository, managerScope) + + manager.setStopAtTop(true) + manager.setBeepsEnabled(false) + advanceUntilIdle() + + val workout = assertIs(fakeProfileRepository.activeProfileContext.value) + .preferences.workout.value + assertTrue(workout.stopAtTop) + assertFalse(workout.beepsEnabled) + } finally { + managerScope.cancel() + } + } + + @Test + fun `queued setter never lands in newly active profile`() = runTest { + fakeProfileRepository.setActiveProfileForTest(id = "profile-a") + val managerScope = CoroutineScope(coroutineContext + SupervisorJob()) + try { + val manager = SettingsManager(fakePreferencesManager, fakeProfileRepository, managerScope) + + manager.setBodyWeightKg(82f) + fakeProfileRepository.setActiveProfileForTest(id = "profile-b") + advanceUntilIdle() + + assertEquals( + 0f, + assertIs(fakeProfileRepository.activeProfileContext.value) + .preferences.core.value.bodyWeightKg, + ) + fakeProfileRepository.setActiveProfileForTest(id = "profile-a") + assertEquals( + 0f, + assertIs(fakeProfileRepository.activeProfileContext.value) + .preferences.core.value.bodyWeightKg, + ) + } finally { + managerScope.cancel() + } + } + + @Test + fun `active profile cascades clear subordinate adult modes`() = runTest { + val managerScope = CoroutineScope(coroutineContext + SupervisorJob()) + try { + val manager = SettingsManager(fakePreferencesManager, fakeProfileRepository, managerScope) + manager.setVerbalEncouragementEnabled(true) + manager.setAdultsOnlyConfirmed(true) + manager.setVulgarModeEnabled(true) + manager.setDominatrixModeUnlocked(true) + manager.setDominatrixModeActive(true) + advanceUntilIdle() + + manager.setVerbalEncouragementEnabled(false) + advanceUntilIdle() + + var vbt = assertIs(fakeProfileRepository.activeProfileContext.value) + .preferences.vbt.value + assertFalse(vbt.verbalEncouragementEnabled) + assertFalse(vbt.vulgarModeEnabled) + assertFalse(vbt.dominatrixModeActive) + + manager.setVerbalEncouragementEnabled(true) + manager.setVulgarModeEnabled(true) + manager.setDominatrixModeActive(true) + manager.setVulgarModeEnabled(false) + advanceUntilIdle() + + vbt = assertIs(fakeProfileRepository.activeProfileContext.value) + .preferences.vbt.value + assertFalse(vbt.vulgarModeEnabled) + assertFalse(vbt.dominatrixModeActive) + } finally { + managerScope.cancel() + } + } + + @Test + fun `adult mode gates and VBT disable preserve subordinate values`() = runTest { + val managerScope = CoroutineScope(coroutineContext + SupervisorJob()) + try { + val manager = SettingsManager(fakePreferencesManager, fakeProfileRepository, managerScope) + + manager.setVulgarModeEnabled(true) + manager.setDominatrixModeUnlocked(true) + manager.setDominatrixModeActive(true) + advanceUntilIdle() + var ready = assertIs(fakeProfileRepository.activeProfileContext.value) + assertFalse(ready.preferences.vbt.value.vulgarModeEnabled) + assertFalse(ready.preferences.vbt.value.dominatrixModeActive) + + manager.setAdultsOnlyPrompted(true) + manager.setVulgarModeEnabled(true) + manager.setDominatrixModeActive(true) + advanceUntilIdle() + ready = assertIs(fakeProfileRepository.activeProfileContext.value) + assertTrue(ready.preferences.vbt.value.vulgarModeEnabled) + assertFalse(ready.preferences.vbt.value.dominatrixModeActive) + + manager.setAdultsOnlyConfirmed(true) + manager.setDominatrixModeActive(true) + manager.setVelocityLossThreshold(35) + manager.setAutoEndOnVelocityLoss(true) + advanceUntilIdle() + manager.setVbtEnabled(false) + advanceUntilIdle() + + val vbt = assertIs(fakeProfileRepository.activeProfileContext.value) + .preferences.vbt.value + assertFalse(vbt.enabled) + assertEquals(35, vbt.velocityLossThresholdPercent) + assertTrue(vbt.autoEndOnVelocityLoss) + assertTrue(vbt.vulgarModeEnabled) + assertTrue(vbt.dominatrixModeActive) + } finally { + managerScope.cancel() + } + } + + @Test + fun `velocity loss threshold is clamped to supported profile range`() = runTest { + val managerScope = CoroutineScope(coroutineContext + SupervisorJob()) + try { + val manager = SettingsManager(fakePreferencesManager, fakeProfileRepository, managerScope) + + manager.setVelocityLossThreshold(5) + advanceUntilIdle() + assertEquals( + 10, + assertIs(fakeProfileRepository.activeProfileContext.value) + .preferences.vbt.value.velocityLossThresholdPercent, + ) + + manager.setVelocityLossThreshold(75) + advanceUntilIdle() + assertEquals( + 50, + assertIs(fakeProfileRepository.activeProfileContext.value) + .preferences.vbt.value.velocityLossThresholdPercent, + ) + } finally { + managerScope.cancel() + } + } + + @Test + fun `confirmation implies prompted while prompted alone never confirms`() = runTest { + val managerScope = CoroutineScope(coroutineContext + SupervisorJob()) + try { + val manager = SettingsManager(fakePreferencesManager, fakeProfileRepository, managerScope) + + manager.setAdultsOnlyPrompted(true) + advanceUntilIdle() + var safety = assertIs(fakeProfileRepository.activeProfileContext.value).localSafety + assertTrue(safety.adultsOnlyPrompted) + assertFalse(safety.adultsOnlyConfirmed) + + manager.setAdultsOnlyConfirmed(true) + advanceUntilIdle() + safety = assertIs(fakeProfileRepository.activeProfileContext.value).localSafety + assertTrue(safety.adultsOnlyPrompted) + assertTrue(safety.adultsOnlyConfirmed) + } finally { + managerScope.cancel() + } + } + + @Test + fun `composite adult confirmation is serialized and never enables profile B`() = runTest { + fakeProfileRepository.setActiveProfileForTest(id = "profile-a") + val managerScope = CoroutineScope(coroutineContext + SupervisorJob()) + try { + val manager = SettingsManager(fakePreferencesManager, fakeProfileRepository, managerScope) + + manager.confirmAdultsAndEnableVulgar() + fakeProfileRepository.setActiveProfileForTest(id = "profile-b") + advanceUntilIdle() + + var ready = assertIs(fakeProfileRepository.activeProfileContext.value) + assertFalse(ready.localSafety.adultsOnlyConfirmed) + assertFalse(ready.preferences.vbt.value.vulgarModeEnabled) + + manager.confirmAdultsAndEnableVulgar() + advanceUntilIdle() + ready = assertIs(fakeProfileRepository.activeProfileContext.value) + assertTrue(ready.localSafety.adultsOnlyConfirmed) + assertTrue(ready.localSafety.adultsOnlyPrompted) + assertTrue(ready.preferences.vbt.value.vulgarModeEnabled) + + fakeProfileRepository.setActiveProfileForTest(id = "profile-a") + ready = assertIs(fakeProfileRepository.activeProfileContext.value) + assertFalse(ready.localSafety.adultsOnlyConfirmed) + assertFalse(ready.preferences.vbt.value.vulgarModeEnabled) + } finally { + managerScope.cancel() + } + } } diff --git a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/viewmodel/AssessmentViewModelProfileScopeTest.kt b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/viewmodel/AssessmentViewModelProfileScopeTest.kt new file mode 100644 index 000000000..8f26f2d9a --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/viewmodel/AssessmentViewModelProfileScopeTest.kt @@ -0,0 +1,155 @@ +package com.devil.phoenixproject.presentation.viewmodel + +import app.cash.turbine.test +import com.devil.phoenixproject.domain.assessment.AssessmentEngine +import com.devil.phoenixproject.domain.model.Exercise +import com.devil.phoenixproject.testutil.FakeAssessmentRepository +import com.devil.phoenixproject.testutil.FakeExerciseRepository +import com.devil.phoenixproject.testutil.TestCoroutineRule +import kotlin.coroutines.cancellation.CancellationException +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class AssessmentViewModelProfileScopeTest { + @get:Rule + val coroutineRule = TestCoroutineRule() + + @Test + fun acceptResult_forwardsExplicitProfileAndPreservesUnitBoundary() = runTest { + val assessmentRepository = FakeAssessmentRepository() + val viewModel = readyViewModel(assessmentRepository) + reachResults(viewModel) + + viewModel.acceptResult(profileId = "athlete-a", overrideKg = 120f) + advanceUntilIdle() + + val saved = assessmentRepository.savedSessions.single() + assertEquals("athlete-a", saved.profileId) + assertEquals(120f, saved.userOverrideTotalKg) + assertEquals(30f, saved.weightPerCableKg) + assertIs(viewModel.currentStep.value) + assertEquals( + 120f, + (viewModel.currentStep.value as AssessmentStep.Complete).finalOneRepMaxKg, + ) + } + + @Test + fun acceptResult_rejectsBlankProfileBeforeStartingSave() = runTest { + val assessmentRepository = FakeAssessmentRepository() + val viewModel = readyViewModel(assessmentRepository) + reachResults(viewModel) + + assertFailsWith { + viewModel.acceptResult(profileId = " ") + } + + assertIs(viewModel.currentStep.value) + assertEquals(emptyList(), assessmentRepository.attemptedSessions) + } + + @Test + fun ordinarySaveFailureRestoresResultsAndEmitsOneEvent() = runTest { + val assessmentRepository = FakeAssessmentRepository().apply { + saveSessionFailure = IllegalStateException("test failure") + } + val viewModel = readyViewModel(assessmentRepository) + reachResults(viewModel) + + viewModel.events.test { + viewModel.acceptResult(profileId = "athlete-a") + advanceUntilIdle() + + assertEquals(AssessmentUiEvent.SaveFailed, awaitItem()) + expectNoEvents() + assertIs(viewModel.currentStep.value) + assertEquals( + "athlete-a", + assessmentRepository.attemptedSessions.single().profileId, + ) + assertEquals(emptyList(), assessmentRepository.savedSessions) + } + } + + @Test + fun cancellationIsNotConvertedIntoRetryOrFailureEvent() = runTest { + val assessmentRepository = FakeAssessmentRepository().apply { + saveSessionFailure = CancellationException("test cancellation") + } + val viewModel = readyViewModel(assessmentRepository) + reachResults(viewModel) + + viewModel.events.test { + viewModel.acceptResult(profileId = "athlete-a") + advanceUntilIdle() + + expectNoEvents() + assertIs(viewModel.currentStep.value) + assertEquals( + "athlete-a", + assessmentRepository.attemptedSessions.single().profileId, + ) + } + } + + @Test + fun startingLoad_isExactlyTwentyTotalKgForEveryProfileDespiteGlobalExerciseOneRm() = + runTest { + val sharedExercises = exerciseRepository(oneRepMaxKg = 100f) + val profileA = readyViewModel(FakeAssessmentRepository(), sharedExercises) + val profileB = readyViewModel(FakeAssessmentRepository(), sharedExercises) + advanceUntilIdle() + + profileA.selectExerciseById("bench") + profileB.selectExerciseById("bench") + advanceUntilIdle() + + assertEquals( + 20f, + (profileA.currentStep.value as AssessmentStep.ProgressiveLoading) + .suggestedWeightKg, + ) + assertEquals( + 20f, + (profileB.currentStep.value as AssessmentStep.ProgressiveLoading) + .suggestedWeightKg, + ) + } + + private fun readyViewModel( + assessmentRepository: FakeAssessmentRepository, + exerciseRepository: FakeExerciseRepository = exerciseRepository(), + ): AssessmentViewModel = AssessmentViewModel( + exerciseRepository = exerciseRepository, + assessmentRepository = assessmentRepository, + assessmentEngine = AssessmentEngine(), + ) + + private fun exerciseRepository(oneRepMaxKg: Float? = null) = + FakeExerciseRepository().apply { + addExercise( + Exercise( + id = "bench", + name = "Bench Press", + muscleGroup = "Chest", + equipment = "BAR", + oneRepMaxKg = oneRepMaxKg, + ), + ) + } + + private suspend fun TestScope.reachResults(viewModel: AssessmentViewModel) { + advanceUntilIdle() + viewModel.selectExerciseById("bench") + advanceUntilIdle() + viewModel.recordSet(40f, 3, 1.0f, 1.1f) + viewModel.recordSet(80f, 3, 0.25f, 0.30f) + assertIs(viewModel.currentStep.value) + } +} diff --git a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/viewmodel/MainViewModelTest.kt b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/viewmodel/MainViewModelTest.kt index b543d074f..e47b7e724 100644 --- a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/viewmodel/MainViewModelTest.kt +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/viewmodel/MainViewModelTest.kt @@ -4,7 +4,7 @@ import androidx.lifecycle.viewModelScope import app.cash.turbine.test import com.devil.phoenixproject.data.repository.RepNotification import com.devil.phoenixproject.data.repository.ScannedDevice -import com.devil.phoenixproject.data.repository.SettingsEquipmentRackRepository +import com.devil.phoenixproject.data.repository.ProfileEquipmentRackRepository import com.devil.phoenixproject.domain.model.ConnectionState import com.devil.phoenixproject.domain.model.Exercise import com.devil.phoenixproject.domain.model.ProgramMode @@ -15,6 +15,7 @@ import com.devil.phoenixproject.domain.model.UserPreferences import com.devil.phoenixproject.domain.model.WeightUnit import com.devil.phoenixproject.domain.model.WorkoutMetric import com.devil.phoenixproject.domain.model.WorkoutParameters +import com.devil.phoenixproject.domain.model.WorkoutPreferences import com.devil.phoenixproject.domain.model.WorkoutState import com.devil.phoenixproject.domain.usecase.ApplyEquipmentRackLoadUseCase import com.devil.phoenixproject.domain.usecase.CountVelocityOneRepMaxImprovementsUseCase @@ -34,8 +35,8 @@ import com.devil.phoenixproject.testutil.FakeRepMetricRepository import com.devil.phoenixproject.testutil.FakeTrainingCycleRepository import com.devil.phoenixproject.testutil.FakeVelocityOneRepMaxRepository import com.devil.phoenixproject.testutil.FakeWorkoutRepository +import com.devil.phoenixproject.testutil.FakeUserProfileRepository import com.devil.phoenixproject.testutil.TestCoroutineRule -import com.russhwolf.settings.MapSettings import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertIs @@ -70,6 +71,8 @@ class MainViewModelTest { private lateinit var fakeRepMetricRepository: FakeRepMetricRepository private lateinit var repCounter: RepCounterFromMachine private lateinit var resolveWeightsUseCase: ResolveRoutineWeightsUseCase + private lateinit var fakeUserProfileRepository: FakeUserProfileRepository + private lateinit var profileEquipmentRackRepository: ProfileEquipmentRackRepository @Before fun setup() { @@ -84,6 +87,11 @@ class MainViewModelTest { fakeRepMetricRepository = FakeRepMetricRepository() repCounter = RepCounterFromMachine() resolveWeightsUseCase = ResolveRoutineWeightsUseCase(fakePersonalRecordRepository, fakeExerciseRepository, FakeVelocityOneRepMaxRepository()) + fakeUserProfileRepository = FakeUserProfileRepository().apply { setActiveProfileForTest() } + profileEquipmentRackRepository = ProfileEquipmentRackRepository( + fakeUserProfileRepository, + kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.SupervisorJob() + kotlinx.coroutines.Dispatchers.Main), + ) viewModel = MainViewModel( bleRepository = fakeBleRepository, @@ -99,10 +107,10 @@ class MainViewModelTest { biomechanicsRepository = FakeBiomechanicsRepository(), resolveWeightsUseCase = resolveWeightsUseCase, recommendWeightAdjustmentUseCase = RecommendWeightAdjustmentUseCase(), - equipmentRackRepository = SettingsEquipmentRackRepository(MapSettings()), + equipmentRackRepository = profileEquipmentRackRepository, applyEquipmentRackLoadUseCase = ApplyEquipmentRackLoadUseCase(), dataBackupManager = FakeDataBackupManager(), - userProfileRepository = com.devil.phoenixproject.testutil.FakeUserProfileRepository(), + userProfileRepository = fakeUserProfileRepository, workoutServiceController = NoOpWorkoutServiceController, computeVelocityOneRepMaxUseCase = com.devil.phoenixproject.domain.usecase.ComputeVelocityOneRepMaxUseCase( workoutPoints = { _, _, _ -> emptyList() }, @@ -143,6 +151,7 @@ class MainViewModelTest { // cancellation, leaving all ViewModel coroutines alive and polluting the test // dispatcher's scheduler for subsequent tests (UncaughtExceptionsBeforeTest). viewModel.viewModelScope.cancel() + profileEquipmentRackRepository.close() } // ========== Initial State Tests ========== @@ -326,14 +335,16 @@ class MainViewModelTest { viewModel.userPreferences.test { awaitItem() // Initial value - fakePreferencesManager.setPreferences( - UserPreferences( - weightUnit = WeightUnit.LB, - stopAtTop = true, - ), + val profileId = assertNotNull(fakeUserProfileRepository.activeProfile.value).id + fakeUserProfileRepository.updateWorkout( + profileId, + WorkoutPreferences(stopAtTop = true), ) - val updated = awaitItem() + var updated = awaitItem() + while (!updated.stopAtTop) { + updated = awaitItem() + } assertEquals(WeightUnit.LB, updated.weightUnit) assertTrue(updated.stopAtTop) @@ -341,6 +352,37 @@ class MainViewModelTest { } } + @Test + fun `globalSettings ignores profile updates and emits global updates`() = + runTest(testCoroutineRule.dispatcher) { + viewModel.globalSettings.test { + val initial = awaitItem() + assertEquals(initial, viewModel.globalSettings.value) + + val profileId = assertNotNull(fakeUserProfileRepository.activeProfile.value).id + fakeUserProfileRepository.updateWorkout( + profileId, + WorkoutPreferences(stopAtTop = true), + ) + advanceUntilIdle() + + expectNoEvents() + assertEquals(initial, viewModel.globalSettings.value) + + val updatedVideoPlayback = !initial.enableVideoPlayback + viewModel.setEnableVideoPlayback(updatedVideoPlayback) + + val updated = awaitItem() + assertEquals( + initial.copy(enableVideoPlayback = updatedVideoPlayback), + updated, + ) + assertEquals(updated, viewModel.globalSettings.value) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } + // ========== Top Bar State Tests ========== @Test diff --git a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileSwitcherViewModelTest.kt b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileSwitcherViewModelTest.kt new file mode 100644 index 000000000..b1b42e2c3 --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileSwitcherViewModelTest.kt @@ -0,0 +1,427 @@ +package com.devil.phoenixproject.presentation.viewmodel + +import com.devil.phoenixproject.data.repository.ProfileContextRecoveryException +import com.devil.phoenixproject.testutil.FakeUserProfileRepository +import com.devil.phoenixproject.testutil.TestCoroutineRule +import kotlin.coroutines.cancellation.CancellationException +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class ProfileSwitcherViewModelTest { + @get:Rule + val coroutineRule = TestCoroutineRule() + + private lateinit var profiles: FakeUserProfileRepository + + @Before + fun setUp() { + profiles = FakeUserProfileRepository() + profiles.seedReadyProfileForTest("a", name = "A") + profiles.seedReadyProfileForTest("b", name = "B") + profiles.seedReadyProfileForTest("c", name = "C") + profiles.emitReadyForTest("a") + } + + @Test + fun `sheet opens during Switching null but cannot dismiss or start an operation`() = runTest { + profiles.emitSwitchingForTest(null) + val viewModel = createViewModel() + + viewModel.openSwitcher() + assertEquals(ProfileSwitcherUiState(showSwitcher = true), viewModel.uiState.value) + + viewModel.dismissSwitcher() + viewModel.switchProfile("b") + advanceUntilIdle() + + assertEquals(ProfileSwitcherUiState(showSwitcher = true), viewModel.uiState.value) + assertTrue(profiles.setActiveProfileRequests.isEmpty()) + assertTrue(profiles.createAndActivateRequests.isEmpty()) + assertEquals(0, profiles.reconcileActiveProfileContextRequests) + } + + @Test + fun `successful switch calls the repository once and closes the sheet`() = runTest { + val viewModel = createViewModel() + viewModel.openSwitcher() + + viewModel.switchProfile("b") + assertEquals( + RootProfileOperation(1L, RootProfileOperationKind.SWITCH, "b"), + viewModel.uiState.value.operation, + ) + assertTrue(viewModel.uiState.value.showSwitcher) + + advanceUntilIdle() + + assertEquals(listOf("b"), profiles.setActiveProfileRequests) + assertEquals(ProfileSwitcherUiState(), viewModel.uiState.value) + } + + @Test + fun `ordinary switch failure keeps the sheet open with only switch error`() = runTest { + profiles.setActiveProfileFailure = IllegalStateException("switch") + val viewModel = createViewModel() + viewModel.openSwitcher() + + viewModel.switchProfile("b") + assertEquals( + RootProfileOperation(1L, RootProfileOperationKind.SWITCH, "b"), + viewModel.uiState.value.operation, + ) + advanceUntilIdle() + + assertEquals(listOf("b"), profiles.setActiveProfileRequests) + assertEquals( + ProfileSwitcherUiState( + showSwitcher = true, + error = ProfileOverlayError.SWITCH_FAILED, + ), + viewModel.uiState.value, + ) + } + + @Test + fun `switch recovery failure closes ordinary overlays and opens blocking recovery`() = runTest { + profiles.setActiveProfileFailure = recoveryFailure() + val viewModel = createViewModel() + viewModel.openSwitcher() + + viewModel.switchProfile("b") + assertEquals( + RootProfileOperation(1L, RootProfileOperationKind.SWITCH, "b"), + viewModel.uiState.value.operation, + ) + advanceUntilIdle() + + assertEquals(listOf("b"), profiles.setActiveProfileRequests) + assertEquals( + ProfileSwitcherUiState(recoveryRequired = true), + viewModel.uiState.value, + ) + } + + @Test + fun `switch cancellation clears only its token and emits no error`() = runTest { + profiles.setActiveProfileFailure = CancellationException("cancel switch") + val viewModel = createViewModel() + viewModel.openSwitcher() + + viewModel.switchProfile("b") + assertEquals( + RootProfileOperation(1L, RootProfileOperationKind.SWITCH, "b"), + viewModel.uiState.value.operation, + ) + advanceUntilIdle() + + assertEquals(listOf("b"), profiles.setActiveProfileRequests) + assertEquals(ProfileSwitcherUiState(showSwitcher = true), viewModel.uiState.value) + } + + @Test + fun `two same-frame switch requests launch only the first`() = runTest { + val release = CompletableDeferred() + profiles.beforeSetActiveProfile = { release.await() } + val viewModel = createViewModel() + viewModel.openSwitcher() + + viewModel.switchProfile("b") + viewModel.switchProfile("c") + assertEquals( + RootProfileOperation(1L, RootProfileOperationKind.SWITCH, "b"), + viewModel.uiState.value.operation, + ) + runCurrent() + + assertEquals(listOf("b"), profiles.setActiveProfileRequests) + release.complete(Unit) + advanceUntilIdle() + assertEquals(ProfileSwitcherUiState(), viewModel.uiState.value) + } + + @Test + fun `noncooperative stale switch completion cannot close a newer recovery state`() = runTest { + val entered = CompletableDeferred() + val release = CompletableDeferred() + profiles.beforeSetActiveProfile = { + entered.complete(Unit) + try { + release.await() + } catch (_: CancellationException) { + // Deliberately non-cooperative: return so the stale repository call completes. + } + } + val viewModel = createViewModel() + viewModel.openSwitcher() + viewModel.switchProfile("b") + runCurrent() + assertTrue(entered.isCompleted) + assertEquals(listOf("b"), profiles.setActiveProfileRequests) + + viewModel.requireRecovery(recoveryFailure()) + assertEquals( + ProfileSwitcherUiState(recoveryRequired = true), + viewModel.uiState.value, + ) + release.complete(Unit) + advanceUntilIdle() + + assertEquals(listOf("b"), profiles.setActiveProfileRequests) + assertEquals( + ProfileSwitcherUiState(recoveryRequired = true), + viewModel.uiState.value, + ) + } + + @Test + fun `add opens from the sheet and dismiss returns to that sheet`() = runTest { + val viewModel = createViewModel() + viewModel.openSwitcher() + + viewModel.openAddDialog() + assertEquals( + ProfileSwitcherUiState(showSwitcher = true, showAddDialog = true), + viewModel.uiState.value, + ) + + profiles.emitSwitchingForTest(null) + viewModel.dismissAddDialog() + assertEquals( + ProfileSwitcherUiState(showSwitcher = true, showAddDialog = true), + viewModel.uiState.value, + ) + + profiles.emitReadyForTest("a") + viewModel.dismissAddDialog() + assertEquals(ProfileSwitcherUiState(showSwitcher = true), viewModel.uiState.value) + assertTrue(profiles.createAndActivateRequests.isEmpty()) + } + + @Test + fun `successful create and activate calls once and closes both overlays`() = runTest { + val viewModel = createViewModel() + openAddDialog(viewModel) + + viewModel.createAndActivateProfile(" New ", 3) + assertEquals( + RootProfileOperation(1L, RootProfileOperationKind.CREATE), + viewModel.uiState.value.operation, + ) + assertTrue(viewModel.uiState.value.showSwitcher) + assertTrue(viewModel.uiState.value.showAddDialog) + advanceUntilIdle() + + assertEquals( + listOf(FakeUserProfileRepository.CreateAndActivateRequest("New", 3)), + profiles.createAndActivateRequests, + ) + assertEquals(ProfileSwitcherUiState(), viewModel.uiState.value) + } + + @Test + fun `ordinary create failure keeps both overlays with only create error`() = runTest { + profiles.createAndActivateProfileFailure = IllegalStateException("create") + val viewModel = createViewModel() + openAddDialog(viewModel) + + viewModel.createAndActivateProfile("New", 3) + assertEquals( + RootProfileOperation(1L, RootProfileOperationKind.CREATE), + viewModel.uiState.value.operation, + ) + advanceUntilIdle() + + assertEquals( + listOf(FakeUserProfileRepository.CreateAndActivateRequest("New", 3)), + profiles.createAndActivateRequests, + ) + assertEquals( + ProfileSwitcherUiState( + showSwitcher = true, + showAddDialog = true, + error = ProfileOverlayError.CREATE_FAILED, + ), + viewModel.uiState.value, + ) + } + + @Test + fun `create recovery failure closes both overlays and opens blocking recovery`() = runTest { + profiles.createAndActivateProfileFailure = recoveryFailure() + val viewModel = createViewModel() + openAddDialog(viewModel) + + viewModel.createAndActivateProfile("New", 3) + assertEquals( + RootProfileOperation(1L, RootProfileOperationKind.CREATE), + viewModel.uiState.value.operation, + ) + advanceUntilIdle() + + assertEquals( + listOf(FakeUserProfileRepository.CreateAndActivateRequest("New", 3)), + profiles.createAndActivateRequests, + ) + assertEquals( + ProfileSwitcherUiState(recoveryRequired = true), + viewModel.uiState.value, + ) + } + + @Test + fun `two same-frame create confirmations launch only the first`() = runTest { + val release = CompletableDeferred() + profiles.beforeCreateAndActivateProfile = { _, _ -> release.await() } + val viewModel = createViewModel() + openAddDialog(viewModel) + + viewModel.createAndActivateProfile("First", 2) + viewModel.createAndActivateProfile("Second", 6) + assertEquals( + RootProfileOperation(1L, RootProfileOperationKind.CREATE), + viewModel.uiState.value.operation, + ) + runCurrent() + + assertEquals( + listOf(FakeUserProfileRepository.CreateAndActivateRequest("First", 2)), + profiles.createAndActivateRequests, + ) + release.complete(Unit) + advanceUntilIdle() + assertEquals(ProfileSwitcherUiState(), viewModel.uiState.value) + } + + @Test + fun `Task 6 recovery handoff cancels ownership and opens the same recovery state`() = runTest { + profiles.beforeCreateAndActivateProfile = { _, _ -> awaitCancellation() } + val viewModel = createViewModel() + openAddDialog(viewModel) + viewModel.createAndActivateProfile("New", 3) + runCurrent() + assertEquals( + listOf(FakeUserProfileRepository.CreateAndActivateRequest("New", 3)), + profiles.createAndActivateRequests, + ) + assertEquals( + RootProfileOperation(1L, RootProfileOperationKind.CREATE), + viewModel.uiState.value.operation, + ) + + viewModel.requireRecovery(recoveryFailure()) + advanceUntilIdle() + + assertEquals( + ProfileSwitcherUiState(recoveryRequired = true), + viewModel.uiState.value, + ) + } + + @Test + fun `successful recovery retry clears the blocking modal`() = runTest { + val viewModel = createViewModel() + viewModel.requireRecovery(recoveryFailure()) + + viewModel.retryRecovery() + assertEquals( + RootProfileOperation(1L, RootProfileOperationKind.RECOVERY), + viewModel.uiState.value.operation, + ) + assertTrue(viewModel.uiState.value.recoveryRequired) + advanceUntilIdle() + + assertEquals(1, profiles.reconcileActiveProfileContextRequests) + assertEquals(ProfileSwitcherUiState(), viewModel.uiState.value) + } + + @Test + fun `failed recovery retry keeps the modal with only retry error`() = runTest { + profiles.reconcileActiveProfileContextFailure = IllegalStateException("retry") + val viewModel = createViewModel() + viewModel.requireRecovery(recoveryFailure()) + + viewModel.retryRecovery() + assertEquals( + RootProfileOperation(1L, RootProfileOperationKind.RECOVERY), + viewModel.uiState.value.operation, + ) + advanceUntilIdle() + + assertEquals(1, profiles.reconcileActiveProfileContextRequests) + assertEquals( + ProfileSwitcherUiState( + recoveryRequired = true, + error = ProfileOverlayError.RECOVERY_RETRY_FAILED, + ), + viewModel.uiState.value, + ) + } + + @Test + fun `two same-frame recovery retries launch only the first`() = runTest { + val release = CompletableDeferred() + profiles.beforeReconcileActiveProfileContext = { release.await() } + val viewModel = createViewModel() + viewModel.requireRecovery(recoveryFailure()) + + viewModel.retryRecovery() + viewModel.retryRecovery() + assertEquals( + RootProfileOperation(1L, RootProfileOperationKind.RECOVERY), + viewModel.uiState.value.operation, + ) + runCurrent() + + assertEquals(1, profiles.reconcileActiveProfileContextRequests) + release.complete(Unit) + advanceUntilIdle() + assertEquals(ProfileSwitcherUiState(), viewModel.uiState.value) + } + + @Test + fun `recovery cancellation clears its token but keeps recovery blocking without an error`() = + runTest { + profiles.reconcileActiveProfileContextFailure = CancellationException("cancel retry") + val viewModel = createViewModel() + viewModel.requireRecovery(recoveryFailure()) + + viewModel.retryRecovery() + assertEquals( + RootProfileOperation(1L, RootProfileOperationKind.RECOVERY), + viewModel.uiState.value.operation, + ) + advanceUntilIdle() + + assertEquals(1, profiles.reconcileActiveProfileContextRequests) + val state = viewModel.uiState.value + assertTrue(state.recoveryRequired) + assertNull(state.operation) + assertNull(state.error) + assertFalse(state.showSwitcher) + assertFalse(state.showAddDialog) + } + + private fun createViewModel() = ProfileSwitcherViewModel(profiles) + + private fun openAddDialog(viewModel: ProfileSwitcherViewModel) { + viewModel.openSwitcher() + viewModel.openAddDialog() + assertEquals( + ProfileSwitcherUiState(showSwitcher = true, showAddDialog = true), + viewModel.uiState.value, + ) + } + + private fun recoveryFailure() = + ProfileContextRecoveryException(IllegalStateException("recovery")) +} diff --git a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileViewModelTest.kt b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileViewModelTest.kt new file mode 100644 index 000000000..04b16fd35 --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileViewModelTest.kt @@ -0,0 +1,2007 @@ +package com.devil.phoenixproject.presentation.viewmodel + +import androidx.lifecycle.viewModelScope +import com.devil.phoenixproject.data.repository.ActiveProfileContext +import com.devil.phoenixproject.data.repository.MAX_RECENT_EXERCISE_SESSIONS +import com.devil.phoenixproject.data.repository.ProfileContextRecoveryException +import com.devil.phoenixproject.domain.model.CoreProfilePreferences +import com.devil.phoenixproject.domain.model.Exercise +import com.devil.phoenixproject.domain.model.ExternalBodyMeasurement +import com.devil.phoenixproject.domain.model.IntegrationProvider +import com.devil.phoenixproject.domain.model.LedPreferences +import com.devil.phoenixproject.domain.model.PRType +import com.devil.phoenixproject.domain.model.PersonalRecord +import com.devil.phoenixproject.domain.model.ProfileLocalSafetyPreferences +import com.devil.phoenixproject.domain.model.RackPreferences +import com.devil.phoenixproject.domain.model.VbtPreferences +import com.devil.phoenixproject.domain.model.WorkoutPreferences +import com.devil.phoenixproject.domain.model.WorkoutSession +import com.devil.phoenixproject.domain.usecase.CurrentOneRepMaxSource +import com.devil.phoenixproject.domain.usecase.ResolveCurrentOneRepMaxUseCase +import com.devil.phoenixproject.testutil.FakeAssessmentRepository +import com.devil.phoenixproject.testutil.FakeExerciseRepository +import com.devil.phoenixproject.testutil.FakeExternalMeasurementRepository +import com.devil.phoenixproject.testutil.FakePersonalRecordRepository +import com.devil.phoenixproject.testutil.FakeUserProfileRepository +import com.devil.phoenixproject.testutil.FakeVelocityOneRepMaxRepository +import com.devil.phoenixproject.testutil.FakeWorkoutRepository +import com.devil.phoenixproject.testutil.TestCoroutineRule +import kotlin.coroutines.cancellation.CancellationException +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ProfileViewModelTest { + @get:Rule + val coroutineRule = TestCoroutineRule() + + private lateinit var profiles: FakeUserProfileRepository + private lateinit var exercises: FakeExerciseRepository + private lateinit var workouts: FakeWorkoutRepository + private lateinit var personalRecords: FakePersonalRecordRepository + private lateinit var velocity: FakeVelocityOneRepMaxRepository + private lateinit var assessments: FakeAssessmentRepository + private lateinit var externalMeasurements: FakeExternalMeasurementRepository + + @Before + fun setUp() { + profiles = FakeUserProfileRepository() + exercises = FakeExerciseRepository() + workouts = FakeWorkoutRepository() + personalRecords = FakePersonalRecordRepository() + velocity = FakeVelocityOneRepMaxRepository() + assessments = FakeAssessmentRepository() + externalMeasurements = FakeExternalMeasurementRepository() + } + + @Test + fun `A to B to A restores selection and Switching clears all visible profile data`() = runTest { + val bench = exercise("bench") + val squat = exercise("squat") + exercises.addExercise(bench) + exercises.addExercise(squat) + profiles.seedReadyProfileForTest("b", name = "B") + profiles.seedReadyProfileForTest("a", name = "A") + val viewModel = createViewModel() + advanceUntilIdle() + + viewModel.selectExercise(bench) + advanceUntilIdle() + + profiles.emitSwitchingForTest("b") + runCurrent() + val switching = viewModel.uiState.value + assertIs(switching.context) + assertNull(switching.selectedExercise) + assertNull(switching.missingExerciseId) + assertNull(switching.selectionFailure) + assertIs(switching.currentOneRepMax) + assertIs(switching.prHighlights) + assertIs(switching.recentSessions) + + profiles.emitReadyForTest("b") + advanceUntilIdle() + viewModel.selectExercise(squat) + advanceUntilIdle() + + profiles.emitSwitchingForTest("a") + runCurrent() + profiles.emitReadyForTest("a") + advanceUntilIdle() + assertEquals("bench", viewModel.uiState.value.selectedExercise?.id) + } + + @Test + fun `no saved selection uses only the Ready profile recent exercise`() = runTest { + exercises.addExercise(exercise("bench")) + exercises.addExercise(exercise("squat")) + workouts.addSession(session("a-bench", "a", "bench", timestamp = 100L)) + workouts.addSession(session("b-squat", "b", "squat", timestamp = 200L)) + profiles.seedReadyProfileForTest("b") + profiles.seedReadyProfileForTest("a") + workouts.recentCompletedRequests.clear() + + val viewModel = createViewModel() + advanceUntilIdle() + + assertEquals("bench", viewModel.uiState.value.selectedExercise?.id) + assertTrue(workouts.recentCompletedRequests.isNotEmpty()) + assertTrue( + workouts.recentCompletedRequests.all { + it.profileId == "a" && + it.exerciseId == "bench" && + it.limit == MAX_RECENT_EXERCISE_SESSIONS + }, + ) + } + + @Test + fun `deleted saved exercise reports the unresolved profile fallback id`() = runTest { + val bench = exercise("bench") + exercises.addExercise(bench) + profiles.seedReadyProfileForTest("b") + profiles.seedReadyProfileForTest("a") + val viewModel = createViewModel() + advanceUntilIdle() + viewModel.selectExercise(bench) + advanceUntilIdle() + + profiles.emitSwitchingForTest("b") + runCurrent() + profiles.emitReadyForTest("b") + advanceUntilIdle() + exercises.reset() + workouts.addSession(session("a-bench", "a", "bench", timestamp = 100L)) + + profiles.emitSwitchingForTest("a") + runCurrent() + profiles.emitReadyForTest("a") + advanceUntilIdle() + + assertNull(viewModel.uiState.value.selectedExercise) + assertEquals("bench", viewModel.uiState.value.missingExerciseId) + assertNull(viewModel.uiState.value.selectionFailure) + } + + @Test + fun `selection fallback failure is distinct from missing and the collector recovers`() = runTest { + exercises.addExercise(exercise("bench")) + workouts.addSession(session("a-bench", "a", "bench", timestamp = 100L)) + val failure = IllegalStateException("fallback") + workouts.mostRecentCompletedExerciseFailure = failure + profiles.seedReadyProfileForTest("a") + val viewModel = createViewModel() + advanceUntilIdle() + + assertSame(failure, viewModel.uiState.value.selectionFailure) + assertNull(viewModel.uiState.value.missingExerciseId) + + workouts.mostRecentCompletedExerciseFailure = null + profiles.emitSwitchingForTest("a") + runCurrent() + profiles.emitReadyForTest("a") + advanceUntilIdle() + + assertEquals("bench", viewModel.uiState.value.selectedExercise?.id) + assertNull(viewModel.uiState.value.selectionFailure) + } + + @Test + fun `null resolver and empty repositories use explicit empty contracts`() = runTest { + val bench = exercise("bench") + exercises.addExercise(bench) + profiles.seedReadyProfileForTest("a") + val viewModel = createViewModel() + advanceUntilIdle() + + viewModel.selectExercise(bench) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertIs(state.currentOneRepMax) + assertEquals( + ProfilePrHighlights(null, null, null), + assertIs>(state.prHighlights).value, + ) + assertTrue( + assertIs>>(state.recentSessions) + .value + .isEmpty(), + ) + } + + @Test + fun `PR failure does not blank one rep max or recent sessions and reset clears controls`() = runTest { + val bench = exercise("bench") + exercises.addExercise(bench) + profiles.seedReadyProfileForTest("a") + val viewModel = createViewModel() + advanceUntilIdle() + workouts.addSession(session("a-bench", "a", "bench", timestamp = 100L)) + val failure = IllegalStateException("prs") + personalRecords.getAllForExerciseFailure = failure + + viewModel.selectExercise(bench) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertIs>(state.currentOneRepMax) + assertSame(failure, assertIs(state.prHighlights).cause) + assertIs>(state.recentSessions) + assertTrue(personalRecords.getAllForExerciseRequests.isNotEmpty()) + + personalRecords.reset() + assertNull(personalRecords.getAllForExerciseFailure) + assertNull(personalRecords.beforeGetAllForExerciseReturn) + assertTrue(personalRecords.getAllForExerciseRequests.isEmpty()) + } + + @Test + fun `resolver failure does not blank PRs or recent sessions`() = runTest { + val bench = exercise("bench") + exercises.addExercise(bench) + profiles.seedReadyProfileForTest("a") + val viewModel = createViewModel() + advanceUntilIdle() + workouts.addSession(session("a-bench", "a", "bench", timestamp = 100L)) + val failure = IllegalStateException("1rm") + velocity.latestPassingFailure = failure + + viewModel.selectExercise(bench) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertSame(failure, assertIs(state.currentOneRepMax).cause) + assertIs>(state.prHighlights) + assertIs>(state.recentSessions) + } + + @Test + fun `recent session failure does not blank an earlier source one rep max or PRs`() = runTest { + val bench = exercise("bench") + exercises.addExercise(bench) + profiles.seedReadyProfileForTest("a") + val viewModel = createViewModel() + advanceUntilIdle() + assessments.saveAssessment( + exerciseId = "bench", + estimatedOneRepMaxKg = 100f, + loadVelocityDataJson = "[]", + sessionId = null, + userOverrideKg = null, + profileId = "a", + ) + val failure = IllegalStateException("recent") + workouts.recentCompletedFailure = failure + + viewModel.selectExercise(bench) + advanceUntilIdle() + + val state = viewModel.uiState.value + val oneRepMax = + assertIs>( + state.currentOneRepMax, + ).value + assertEquals(CurrentOneRepMaxSource.ASSESSMENT, oneRepMax.source) + assertIs>(state.prHighlights) + assertSame(failure, assertIs(state.recentSessions).cause) + } + + @Test + fun `mixed A and B records sessions and selections never cross profiles`() = runTest { + exercises.addExercise(exercise("bench")) + workouts.addSession( + session("a-bench", "a", "bench", timestamp = 100L, weightPerCableKg = 40f), + ) + workouts.addSession( + session("b-bench", "b", "bench", timestamp = 200L, weightPerCableKg = 80f), + ) + personalRecords.addRecord( + record(1L, "a", "bench", PRType.MAX_WEIGHT, 45f, 50f, 225f), + ) + personalRecords.addRecord( + record(2L, "a", "bench", PRType.MAX_VOLUME, 40f, 48f, 300f), + ) + personalRecords.addRecord( + record(3L, "b", "bench", PRType.MAX_WEIGHT, 85f, 95f, 425f), + ) + personalRecords.addRecord( + record(4L, "b", "bench", PRType.MAX_VOLUME, 80f, 90f, 600f), + ) + profiles.seedReadyProfileForTest("b") + profiles.seedReadyProfileForTest("a") + val viewModel = createViewModel() + advanceUntilIdle() + + val aState = viewModel.uiState.value + assertEquals( + ProfilePrHighlights(45f, 50f, 300f), + assertIs>(aState.prHighlights).value, + ) + assertTrue( + assertIs>>(aState.recentSessions) + .value + .all { it.profileId == "a" }, + ) + + profiles.emitSwitchingForTest("b") + runCurrent() + profiles.emitReadyForTest("b") + advanceUntilIdle() + + val bState = viewModel.uiState.value + assertEquals( + ProfilePrHighlights(85f, 95f, 600f), + assertIs>(bState.prHighlights).value, + ) + assertTrue( + assertIs>>(bState.recentSessions) + .value + .all { it.profileId == "b" }, + ) + assertEquals(listOf("a", "b"), personalRecords.getAllForExerciseRequests.map { it.profileId }) + assertEquals( + setOf("a", "b"), + workouts.recentCompletedRequests.map { it.profileId }.toSet(), + ) + } + + @Test + fun `same profile Ready refresh updates context without restarting insights`() = runTest { + exercises.addExercise(exercise("bench")) + workouts.addSession(session("a-bench", "a", "bench", timestamp = 100L)) + profiles.seedReadyProfileForTest("a") + val viewModel = createViewModel() + advanceUntilIdle() + val before = viewModel.uiState.value + val workoutRequestCount = workouts.recentCompletedRequests.size + val personalRecordRequestCount = personalRecords.getAllForExerciseRequests.size + val ready = assertIs(before.context) + + profiles.updateCore( + "a", + ready.preferences.core.value.copy(bodyWeightKg = 82f), + ) + advanceUntilIdle() + + val after = viewModel.uiState.value + assertEquals( + 82f, + assertIs(after.context).preferences.core.value.bodyWeightKg, + ) + assertEquals(before.selectedExercise, after.selectedExercise) + assertEquals(before.currentOneRepMax, after.currentOneRepMax) + assertEquals(before.prHighlights, after.prHighlights) + assertEquals(before.recentSessions, after.recentSessions) + assertEquals(workoutRequestCount, workouts.recentCompletedRequests.size) + assertEquals(personalRecordRequestCount, personalRecords.getAllForExerciseRequests.size) + } + + @Test + fun `nullable blank and switching time selections are ignored`() = runTest { + val bench = exercise("bench") + exercises.addExercise(bench) + profiles.seedReadyProfileForTest("b") + profiles.seedReadyProfileForTest("a") + val viewModel = createViewModel() + advanceUntilIdle() + val initialWorkoutRequests = workouts.recentCompletedRequests.size + val initialPrRequests = personalRecords.getAllForExerciseRequests.size + + viewModel.selectExercise(Exercise(name = "Null", muscleGroup = "Other", id = null)) + viewModel.selectExercise(Exercise(name = "Blank", muscleGroup = "Other", id = " ")) + advanceUntilIdle() + assertEquals(initialWorkoutRequests, workouts.recentCompletedRequests.size) + assertEquals(initialPrRequests, personalRecords.getAllForExerciseRequests.size) + + profiles.emitSwitchingForTest("b") + viewModel.selectExercise(bench) + runCurrent() + profiles.emitReadyForTest("b") + advanceUntilIdle() + + assertNull(viewModel.uiState.value.selectedExercise) + assertEquals(initialWorkoutRequests, workouts.recentCompletedRequests.size) + assertEquals(initialPrRequests, personalRecords.getAllForExerciseRequests.size) + } + + @Test + fun `cooperative insight cancellation is never converted to Failed`() = runTest { + val bench = exercise("bench") + exercises.addExercise(bench) + profiles.seedReadyProfileForTest("b") + profiles.seedReadyProfileForTest("a") + val viewModel = createViewModel() + advanceUntilIdle() + val started = CompletableDeferred() + val canceled = CompletableDeferred() + personalRecords.beforeGetAllForExerciseReturn = { _, profileId -> + if (profileId == "a") { + started.complete(Unit) + try { + awaitCancellation() + } finally { + canceled.complete(Unit) + } + } + } + + viewModel.selectExercise(bench) + started.await() + profiles.emitSwitchingForTest("b") + runCurrent() + canceled.await() + + val state = viewModel.uiState.value + assertIs(state.context) + assertIs(state.currentOneRepMax) + assertIs(state.prHighlights) + assertIs(state.recentSessions) + } + + @Test + fun `non cooperative slow A result cannot overwrite B`() = runTest { + val bench = exercise("bench") + exercises.addExercise(bench) + personalRecords.addRecord( + record(1L, "a", "bench", PRType.MAX_WEIGHT, 40f, 45f, 200f), + ) + personalRecords.addRecord( + record(2L, "b", "bench", PRType.MAX_WEIGHT, 80f, 90f, 400f), + ) + profiles.seedReadyProfileForTest("b") + profiles.seedReadyProfileForTest("a") + val viewModel = createViewModel() + advanceUntilIdle() + val aStarted = CompletableDeferred() + val staleAReturned = CompletableDeferred() + personalRecords.beforeGetAllForExerciseReturn = { _, profileId -> + if (profileId == "a") { + aStarted.complete(Unit) + try { + awaitCancellation() + } catch (_: CancellationException) { + // Deliberately return a stale snapshot to exercise publication guards. + } + staleAReturned.complete(Unit) + } + } + + viewModel.selectExercise(bench) + aStarted.await() + profiles.emitSwitchingForTest("b") + runCurrent() + profiles.emitReadyForTest("b") + advanceUntilIdle() + viewModel.selectExercise(bench) + advanceUntilIdle() + staleAReturned.await() + + assertEquals( + ProfilePrHighlights(80f, 90f, null), + assertIs>( + viewModel.uiState.value.prHighlights, + ).value, + ) + } + + @Test + fun `non finite and non positive PR values are excluded per field`() = runTest { + val bench = exercise("bench") + exercises.addExercise(bench) + profiles.seedReadyProfileForTest("a") + val viewModel = createViewModel() + advanceUntilIdle() + listOf( + record( + 1L, + "a", + "bench", + PRType.MAX_WEIGHT, + Float.NaN, + Float.NaN, + 10f, + "nan", + ), + record( + 2L, + "a", + "bench", + PRType.MAX_WEIGHT, + Float.POSITIVE_INFINITY, + Float.POSITIVE_INFINITY, + 20f, + "infinite", + ), + record(3L, "a", "bench", PRType.MAX_WEIGHT, 0f, 0f, 30f, "zero"), + record(4L, "a", "bench", PRType.MAX_WEIGHT, -1f, -2f, 40f, "negative"), + record(5L, "a", "bench", PRType.MAX_WEIGHT, 50f, 70f, 50f, "valid-weight"), + record(6L, "a", "bench", PRType.MAX_VOLUME, 20f, 60f, 0f, "zero-volume"), + record(7L, "a", "bench", PRType.MAX_VOLUME, 30f, 65f, -1f, "negative-volume"), + record( + 8L, + "a", + "bench", + PRType.MAX_VOLUME, + 35f, + 68f, + Float.POSITIVE_INFINITY, + "infinite-volume", + ), + record(9L, "a", "bench", PRType.MAX_VOLUME, 40f, 69f, 500f, "valid-volume"), + ).forEach(personalRecords::addRecord) + + viewModel.selectExercise(bench) + advanceUntilIdle() + + assertEquals( + ProfilePrHighlights(50f, 70f, 500f), + assertIs>( + viewModel.uiState.value.prHighlights, + ).value, + ) + } + + @Test + fun `trimmed normalized update targets current Ready and succeeds after authoritative value`() = runTest { + profiles.seedReadyProfileForTest("a", name = "Before", colorIndex = 2) + val viewModel = createViewModel() + val events = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.events.toList(events) + } + runCurrent() + + viewModel.updateIdentity(" After ", 99) + + val inFlight = assertNotNull(viewModel.uiState.value.identityMutation) + assertEquals("a", inFlight.profileId) + assertEquals(ProfileIdentityMutationKind.UPDATE, inFlight.kind) + advanceUntilIdle() + + assertEquals( + FakeUserProfileRepository.UpdateProfileRequest("a", "After", 0), + profiles.updateProfileRequests.single(), + ) + val ready = assertIs(profiles.activeProfileContext.value) + assertEquals("After", ready.profile.name) + assertEquals(0, ready.profile.colorIndex) + assertNull(viewModel.uiState.value.identityMutation) + assertEquals(listOf(ProfileUiEvent.IdentityUpdated("a")), events) + } + + @Test + fun `ordinary update failure preserves authoritative state clears token and emits scoped failure`() = runTest { + profiles.seedReadyProfileForTest("a", name = "Before", colorIndex = 2) + profiles.updateProfileFailure = IllegalStateException("update failed") + val viewModel = createViewModel() + val events = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.events.toList(events) + } + runCurrent() + + viewModel.updateIdentity("After", 3) + advanceUntilIdle() + + val ready = assertIs(profiles.activeProfileContext.value) + assertEquals("Before", ready.profile.name) + assertEquals(2, ready.profile.colorIndex) + assertNull(viewModel.uiState.value.identityMutation) + assertEquals( + listOf(ProfileUiEvent.IdentityUpdateFailed("a", ProfileIdentityMutationKind.UPDATE)), + events, + ) + } + + @Test + fun `identity cancellation emits no terminal event and clears only its token`() = runTest { + profiles.seedReadyProfileForTest("a") + profiles.updateProfileFailure = CancellationException("cancel update") + val viewModel = createViewModel() + val events = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.events.toList(events) + } + runCurrent() + + viewModel.updateIdentity("After", 1) + val token = assertNotNull(viewModel.uiState.value.identityMutation).token + advanceUntilIdle() + + assertTrue(token > 0) + assertNull(viewModel.uiState.value.identityMutation) + assertTrue(events.isEmpty()) + } + + @Test + fun `overlapping update and delete are rejected synchronously`() = runTest { + profiles.seedReadyProfileForTest("default") + profiles.seedReadyProfileForTest("a") + val updateGate = CompletableDeferred() + profiles.beforeUpdateProfileMutation = { updateGate.await() } + val viewModel = createViewModel() + runCurrent() + + viewModel.updateIdentity("After", 1) + runCurrent() + viewModel.deleteActiveProfile() + + assertTrue(viewModel.uiState.value.identityMutationInFlight) + assertTrue(profiles.deleteActiveProfileRequests.isEmpty()) + updateGate.complete(Unit) + advanceUntilIdle() + assertEquals(1, profiles.updateProfileRequests.size) + } + + @Test + fun `same profile Ready refresh preserves mutation token without restarting insights`() = runTest { + val bench = exercise("bench") + exercises.addExercise(bench) + profiles.seedReadyProfileForTest("a") + val viewModel = createViewModel() + runCurrent() + viewModel.selectExercise(bench) + advanceUntilIdle() + val recentCalls = workouts.recentCompletedRequests.size + val prCalls = personalRecords.getAllForExerciseRequests.size + val updateGate = CompletableDeferred() + profiles.beforeUpdateProfileMutation = { updateGate.await() } + + viewModel.updateIdentity("After", 1) + runCurrent() + val token = assertNotNull(viewModel.uiState.value.identityMutation).token + val ready = assertIs(profiles.activeProfileContext.value) + profiles.updateCore( + "a", + ready.preferences.core.value.copy(bodyWeightKg = 82f), + ) + runCurrent() + + assertEquals(token, assertNotNull(viewModel.uiState.value.identityMutation).token) + assertEquals(82f, assertIs(viewModel.uiState.value.context).preferences.core.value.bodyWeightKg) + assertEquals(recentCalls, workouts.recentCompletedRequests.size) + assertEquals(prCalls, personalRecords.getAllForExerciseRequests.size) + updateGate.complete(Unit) + advanceUntilIdle() + } + + @Test + fun `gated update followed by A to B cannot mutate B or publish stale A event`() = runTest { + profiles.seedReadyProfileForTest("b", name = "B") + profiles.seedReadyProfileForTest("a", name = "A") + val updateStarted = CompletableDeferred() + profiles.beforeUpdateProfileMutation = { request -> + if (request.profileId == "a") { + updateStarted.complete(Unit) + try { + awaitCancellation() + } catch (_: CancellationException) { + // Deliberately non-cooperative so post-call publication guards are exercised. + } + } + } + val viewModel = createViewModel() + val events = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.events.toList(events) + } + runCurrent() + + viewModel.updateIdentity("Stale A", 3) + runCurrent() + updateStarted.await() + profiles.emitSwitchingForTest("b") + runCurrent() + profiles.emitReadyForTest("b") + advanceUntilIdle() + + val ready = assertIs(profiles.activeProfileContext.value) + assertEquals("b", ready.profile.id) + assertEquals("B", ready.profile.name) + assertTrue(events.isEmpty()) + assertNull(viewModel.uiState.value.identityMutation) + } + + @Test + fun `Default deletion never reaches repository`() = runTest { + profiles.seedReadyProfileForTest("default") + val viewModel = createViewModel() + val events = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.events.toList(events) + } + runCurrent() + + viewModel.deleteActiveProfile() + advanceUntilIdle() + + assertTrue(profiles.deleteActiveProfileRequests.isEmpty()) + assertFalse(viewModel.uiState.value.identityMutationInFlight) + assertTrue(events.isEmpty()) + } + + @Test + fun `successful active deletion emits scoped ProfileDeleted after busy clears`() = runTest { + profiles.seedReadyProfileForTest("default") + profiles.seedReadyProfileForTest("a") + val viewModel = createViewModel() + val events = mutableListOf() + val busyWhenObserved = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.events.collect { event -> + events += event + busyWhenObserved += viewModel.uiState.value.identityMutationInFlight + } + } + runCurrent() + + viewModel.deleteActiveProfile() + advanceUntilIdle() + + assertEquals(listOf("a"), profiles.deleteActiveProfileRequests) + assertEquals(listOf(ProfileUiEvent.ProfileDeleted("a")), events) + assertEquals(listOf(false), busyWhenObserved) + assertEquals( + "default", + assertIs(profiles.activeProfileContext.value).profile.id, + ) + } + + @Test + fun `false active deletion emits scoped delete failure`() = runTest { + profiles.seedReadyProfileForTest("default") + profiles.seedReadyProfileForTest("a") + profiles.deleteActiveProfileResultOverride = false + val viewModel = createViewModel() + val events = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.events.toList(events) + } + runCurrent() + + viewModel.deleteActiveProfile() + advanceUntilIdle() + + assertEquals( + listOf(ProfileUiEvent.IdentityUpdateFailed("a", ProfileIdentityMutationKind.DELETE)), + events, + ) + assertNull(viewModel.uiState.value.identityMutation) + } + + @Test + fun `ordinary delete exception emits scoped failure and preserves active profile`() = runTest { + profiles.seedReadyProfileForTest("default") + profiles.seedReadyProfileForTest("a") + profiles.deleteProfileFailure = IllegalStateException("delete failed") + val viewModel = createViewModel() + val events = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.events.toList(events) + } + runCurrent() + + viewModel.deleteActiveProfile() + advanceUntilIdle() + + assertEquals( + listOf(ProfileUiEvent.IdentityUpdateFailed("a", ProfileIdentityMutationKind.DELETE)), + events, + ) + assertEquals( + "a", + assertIs(profiles.activeProfileContext.value).profile.id, + ) + assertNull(viewModel.uiState.value.identityMutation) + } + + @Test + fun `active deletion rollback emits scoped failure after Switching Ready restoration`() = runTest { + profiles.seedReadyProfileForTest("default") + profiles.seedReadyProfileForTest("a") + profiles.failBeforeProfileDeletionCommit = true + val viewModel = createViewModel() + val events = mutableListOf() + val contexts = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.events.toList(events) + } + val contextJob = backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + profiles.activeProfileContext.drop(1).take(2).toList(contexts) + } + runCurrent() + + viewModel.deleteActiveProfile() + advanceUntilIdle() + + assertEquals(listOf("a"), profiles.deleteActiveProfileRequests) + assertEquals(2, contexts.size) + assertEquals("default", assertIs(contexts[0]).targetProfileId) + assertEquals("a", assertIs(contexts[1]).profile.id) + assertEquals( + listOf( + ProfileUiEvent.IdentityUpdateFailed("a", ProfileIdentityMutationKind.DELETE), + ), + events, + ) + assertEquals( + "a", + assertIs(viewModel.uiState.value.context).profile.id, + ) + assertNull(viewModel.uiState.value.identityMutation) + contextJob.cancel() + } + + @Test + fun `recovery exception emits only scoped recovery event`() = runTest { + profiles.seedReadyProfileForTest("default") + profiles.seedReadyProfileForTest("a") + val recovery = ProfileContextRecoveryException(IllegalStateException("reconcile")) + profiles.deleteProfileFailure = recovery + val viewModel = createViewModel() + val events = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.events.toList(events) + } + runCurrent() + + viewModel.deleteActiveProfile() + advanceUntilIdle() + + assertEquals(1, events.size) + val event = assertIs(events.single()) + assertEquals("a", event.profileId) + assertSame(recovery, event.cause) + assertNull(viewModel.uiState.value.identityMutation) + } + + @Test + fun `all six typed updates capture Ready ID and refresh authoritative sections before release`() = runTest { + profiles.seedReadyProfileForTest("a") + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val core = CoreProfilePreferences(bodyWeightKg = 80f) + val rack = RackPreferences() + val workout = WorkoutPreferences(stopAtTop = true) + val led = LedPreferences(colorScheme = 3) + val vbt = VbtPreferences(velocityLossThresholdPercent = 30) + val safety = ProfileLocalSafetyPreferences(safeWord = "phoenix") + val viewModel = createViewModel() + val snapshots = mutableListOf() + var acceptedCallReturned = true + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.events.collect { event -> + if (event is ProfileUiEvent.PreferenceMutationSucceeded) { + assertTrue( + acceptedCallReturned, + "success arrived before ProfileScreen could track the returned token", + ) + snapshots += PreferenceSuccessSnapshot( + event = event, + uiState = viewModel.uiState.value, + repositoryReady = assertIs( + profiles.activeProfileContext.value, + ), + ) + } + } + } + advanceUntilIdle() + + suspend fun accept(call: () -> Long?): Long { + acceptedCallReturned = false + return withContext(Dispatchers.Main) { + val token = call() + acceptedCallReturned = true + assertNotNull(token) + } + } + + accept { viewModel.updateCore(core) } + advanceUntilIdle() + accept { viewModel.updateRack(rack) } + advanceUntilIdle() + accept { viewModel.updateWorkout(workout) } + advanceUntilIdle() + accept { viewModel.updateLed(led) } + advanceUntilIdle() + accept { viewModel.updateVbt(vbt) } + advanceUntilIdle() + accept { viewModel.updateLocalSafety(safety) } + advanceUntilIdle() + + assertEquals( + listOf( + FakeUserProfileRepository.PreferenceUpdateRequest.Core("a", core), + FakeUserProfileRepository.PreferenceUpdateRequest.Rack("a", rack), + FakeUserProfileRepository.PreferenceUpdateRequest.Workout("a", workout), + FakeUserProfileRepository.PreferenceUpdateRequest.Led("a", led), + FakeUserProfileRepository.PreferenceUpdateRequest.Vbt("a", vbt), + FakeUserProfileRepository.PreferenceUpdateRequest.LocalSafety("a", safety), + ), + profiles.preferenceUpdateRequests, + ) + assertEquals( + listOf( + ProfilePreferenceSection.CORE, + ProfilePreferenceSection.RACK, + ProfilePreferenceSection.WORKOUT, + ProfilePreferenceSection.LED, + ProfilePreferenceSection.VBT, + ProfilePreferenceSection.LOCAL_SAFETY, + ), + snapshots.map { it.event.sections.single() }, + ) + snapshots.forEach { snapshot -> + val event = snapshot.event + val uiReady = assertIs(snapshot.uiState.context) + val repositoryReady = snapshot.repositoryReady + val section = event.sections.single() + assertEquals("a", event.profileId) + assertEquals(ProfilePreferenceMutationKind.UPDATE, event.kind) + assertEquals("a", uiReady.profile.id) + assertEquals("a", repositoryReady.profile.id) + assertFalse(section in snapshot.uiState.busyPreferenceSections) + when (section) { + ProfilePreferenceSection.CORE -> { + assertEquals(core, uiReady.preferences.core.value) + assertEquals(repositoryReady.preferences.core, uiReady.preferences.core) + assertTrue(uiReady.preferences.core.metadata.localGeneration > 0L) + } + ProfilePreferenceSection.RACK -> { + assertEquals(rack, uiReady.preferences.rack.value) + assertEquals(repositoryReady.preferences.rack, uiReady.preferences.rack) + assertTrue(uiReady.preferences.rack.metadata.localGeneration > 0L) + } + ProfilePreferenceSection.WORKOUT -> { + assertEquals(workout, uiReady.preferences.workout.value) + assertEquals(repositoryReady.preferences.workout, uiReady.preferences.workout) + assertTrue(uiReady.preferences.workout.metadata.localGeneration > 0L) + } + ProfilePreferenceSection.LED -> { + assertEquals(led, uiReady.preferences.led.value) + assertEquals(repositoryReady.preferences.led, uiReady.preferences.led) + assertTrue(uiReady.preferences.led.metadata.localGeneration > 0L) + } + ProfilePreferenceSection.VBT -> { + assertEquals(vbt, uiReady.preferences.vbt.value) + assertEquals(repositoryReady.preferences.vbt, uiReady.preferences.vbt) + assertTrue(uiReady.preferences.vbt.metadata.localGeneration > 0L) + } + ProfilePreferenceSection.LOCAL_SAFETY -> { + assertEquals(safety, uiReady.localSafety) + assertEquals(repositoryReady.localSafety, uiReady.localSafety) + } + } + } + val ready = assertIs(viewModel.uiState.value.context) + assertEquals(core, ready.preferences.core.value) + assertEquals(rack, ready.preferences.rack.value) + assertEquals(workout, ready.preferences.workout.value) + assertEquals(led, ready.preferences.led.value) + assertEquals(vbt, ready.preferences.vbt.value) + assertEquals(safety, ready.localSafety) + } + + @Test + fun `same section rejects synchronously while another section proceeds`() = runTest { + profiles.seedReadyProfileForTest("a") + val coreGate = CompletableDeferred() + profiles.beforePreferenceUpdate = { request -> + if (request is FakeUserProfileRepository.PreferenceUpdateRequest.Core) coreGate.await() + } + val viewModel = createViewModel() + advanceUntilIdle() + + val first = assertNotNull(viewModel.updateCore(CoreProfilePreferences(bodyWeightKg = 80f))) + assertNull(viewModel.updateCore(CoreProfilePreferences(bodyWeightKg = 81f))) + val led = assertNotNull(viewModel.updateLed(LedPreferences(colorScheme = 2))) + + assertTrue(first != led) + assertEquals( + setOf(ProfilePreferenceSection.CORE, ProfilePreferenceSection.LED), + viewModel.uiState.value.busyPreferenceSections, + ) + coreGate.complete(Unit) + advanceUntilIdle() + assertEquals(1, profiles.preferenceUpdateRequests.count { + it is FakeUserProfileRepository.PreferenceUpdateRequest.Core + }) + assertEquals(1, profiles.preferenceUpdateRequests.count { + it is FakeUserProfileRepository.PreferenceUpdateRequest.Led + }) + } + + @Test + fun `preference and identity claims reject cross domain overlap both directions`() = runTest { + profiles.seedReadyProfileForTest("a") + val preferenceGate = CompletableDeferred() + profiles.beforePreferenceUpdate = { request -> + if (request is FakeUserProfileRepository.PreferenceUpdateRequest.Core) preferenceGate.await() + } + val viewModel = createViewModel() + advanceUntilIdle() + + assertNotNull(viewModel.updateCore(CoreProfilePreferences(bodyWeightKg = 80f))) + viewModel.updateIdentity("Blocked by preference", 1) + assertTrue(profiles.updateProfileRequests.isEmpty()) + + preferenceGate.complete(Unit) + advanceUntilIdle() + val identityGate = CompletableDeferred() + profiles.beforeUpdateProfileMutation = { identityGate.await() } + viewModel.updateIdentity("Identity owner", 2) + assertNotNull(viewModel.uiState.value.identityMutation) + assertNull(viewModel.updateLed(LedPreferences(colorScheme = 4))) + runCurrent() + assertTrue(profiles.preferenceUpdateRequests.none { + it is FakeUserProfileRepository.PreferenceUpdateRequest.Led + }) + + identityGate.complete(Unit) + advanceUntilIdle() + } + + @Test + fun `ordinary preference failure is token profile and section scoped`() = runTest { + profiles.seedReadyProfileForTest("a") + profiles.updateWorkoutFailure = IllegalStateException("workout") + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val viewModel = createViewModel() + val events = mutableListOf() + var acceptedCallReturned = true + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.events.collect { event -> + if (event is ProfileUiEvent.PreferenceUpdateFailed) { + assertTrue( + acceptedCallReturned, + "failure arrived before ProfileScreen could track the returned token", + ) + } + events += event + } + } + advanceUntilIdle() + + acceptedCallReturned = false + val acceptedToken = withContext(Dispatchers.Main) { + val token = viewModel.updateWorkout(WorkoutPreferences(stopAtTop = true)) + acceptedCallReturned = true + token + } + val token = assertNotNull(acceptedToken) + advanceUntilIdle() + + val failure = assertIs(events.single()) + assertEquals("a", failure.profileId) + assertEquals(token, failure.token) + assertEquals(ProfilePreferenceMutationKind.UPDATE, failure.kind) + assertEquals(setOf(ProfilePreferenceSection.WORKOUT), failure.sections) + assertTrue(failure.committedSections.isEmpty()) + assertTrue(viewModel.uiState.value.busyPreferenceSections.isEmpty()) + assertFalse( + assertIs(viewModel.uiState.value.context) + .preferences.workout.value.stopAtTop, + ) + } + + @Test + fun `stale A completion cannot clear a later owner or publish an unowned outcome`() = runTest { + profiles.seedReadyProfileForTest("b") + profiles.seedReadyProfileForTest("a") + val oldStarted = CompletableDeferred() + val oldRelease = CompletableDeferred() + profiles.beforePreferenceUpdate = { request -> + if (request.profileId == "a") { + oldStarted.complete(Unit) + withContext(NonCancellable) { oldRelease.await() } + } + } + val viewModel = createViewModel() + val events = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.events.toList(events) + } + advanceUntilIdle() + + val oldToken = assertNotNull(viewModel.updateCore(CoreProfilePreferences(bodyWeightKg = 80f))) + runCurrent() + oldStarted.await() + profiles.emitSwitchingForTest("b") + runCurrent() + profiles.emitReadyForTest("b") + runCurrent() + oldRelease.complete(Unit) + advanceUntilIdle() + + val laterGate = CompletableDeferred() + profiles.beforePreferenceUpdate = { request -> + if (request.profileId == "b") laterGate.await() + } + val laterToken = assertNotNull( + viewModel.updateCore(CoreProfilePreferences(bodyWeightKg = 90f)), + ) + assertTrue(laterToken > oldToken) + val eventCountBeforeStaleClear = events.size + val clearMethod = ProfileViewModel::class.java + .getDeclaredMethod("clearPreferenceMutation", java.lang.Long.TYPE) + .apply { isAccessible = true } + assertFalse(clearMethod.invoke(viewModel, oldToken) as Boolean) + assertEquals( + laterToken, + viewModel.uiState.value.preferenceMutations + .getValue(ProfilePreferenceSection.CORE) + .token, + ) + runCurrent() + assertEquals(eventCountBeforeStaleClear, events.size) + + laterGate.complete(Unit) + advanceUntilIdle() + assertTrue(events.none { + it is ProfileUiEvent.PreferenceMutationSucceeded && + it.profileId == "a" && + it.token == oldToken + }) + } + + @Test + fun `same profile Ready preserves preference owner and exercise insights`() = runTest { + val bench = exercise("bench") + exercises.addExercise(bench) + profiles.seedReadyProfileForTest("a") + val viewModel = createViewModel() + advanceUntilIdle() + viewModel.selectExercise(bench) + advanceUntilIdle() + val recentCalls = workouts.recentCompletedRequests.size + val prCalls = personalRecords.getAllForExerciseRequests.size + val gate = CompletableDeferred() + profiles.beforePreferenceUpdate = { gate.await() } + + val token = assertNotNull(viewModel.updateCore(CoreProfilePreferences(bodyWeightKg = 80f))) + runCurrent() + profiles.emitReadyForTest("a") + runCurrent() + + assertEquals( + token, + viewModel.uiState.value.preferenceMutations + .getValue(ProfilePreferenceSection.CORE) + .token, + ) + assertEquals("bench", viewModel.uiState.value.selectedExercise?.id) + assertEquals(recentCalls, workouts.recentCompletedRequests.size) + assertEquals(prCalls, personalRecords.getAllForExerciseRequests.size) + assertIs>(viewModel.uiState.value.prHighlights) + gate.complete(Unit) + advanceUntilIdle() + } + + @Test + fun `Switching preserves ownership while clearing visible preference data`() = runTest { + val bench = exercise("bench") + exercises.addExercise(bench) + profiles.seedReadyProfileForTest("a") + val viewModel = createViewModel() + advanceUntilIdle() + viewModel.selectExercise(bench) + advanceUntilIdle() + val gate = CompletableDeferred() + profiles.beforePreferenceUpdate = { gate.await() } + + val token = assertNotNull(viewModel.updateCore(CoreProfilePreferences(bodyWeightKg = 80f))) + runCurrent() + profiles.emitSwitchingForTest("b") + runCurrent() + + val switching = viewModel.uiState.value + assertIs(switching.context) + assertEquals( + token, + switching.preferenceMutations.getValue(ProfilePreferenceSection.CORE).token, + ) + assertNull(switching.importedBodyWeightMeasuredAt) + assertNull(switching.selectedExercise) + assertIs(switching.currentOneRepMax) + assertIs(switching.prHighlights) + assertIs(switching.recentSessions) + + gate.complete(Unit) + advanceUntilIdle() + } + + @Test + fun `measurement attribution restarts separately for Core generation and body weight changes`() = runTest { + profiles.seedReadyProfileForTest("a") + profiles.updateCore("a", CoreProfilePreferences(bodyWeightKg = 80f)) + val generationRelease = CompletableDeferred() + val bodyWeightRelease = CompletableDeferred() + val weight80 = measurement("a-80", "a", 80.0, 100L) + val weight81 = measurement("a-81", "a", 81.0, 200L) + var observationCount = 0 + externalMeasurements.observeByTypeOverride = { profileId, _ -> + check(profileId == "a") + when (++observationCount) { + 1 -> flowOf(listOf(weight80)) + 2 -> flow { + generationRelease.await() + emit(listOf(weight80)) + } + 3 -> flow { + bodyWeightRelease.await() + emit(listOf(weight81)) + } + else -> flowOf(emptyList()) + } + } + profiles.preferenceUpdateRequests.clear() + val viewModel = createViewModel() + advanceUntilIdle() + + assertEquals(100L, viewModel.uiState.value.importedBodyWeightMeasuredAt) + assertEquals(1, externalMeasurements.observationRequests.size) + + profiles.updateCore("a", CoreProfilePreferences(bodyWeightKg = 80f)) + runCurrent() + + val generationOnlyReady = assertIs(viewModel.uiState.value.context) + assertEquals(80f, generationOnlyReady.preferences.core.value.bodyWeightKg) + assertNull(viewModel.uiState.value.importedBodyWeightMeasuredAt) + assertEquals(2, externalMeasurements.observationRequests.size) + + generationRelease.complete(Unit) + runCurrent() + assertEquals(100L, viewModel.uiState.value.importedBodyWeightMeasuredAt) + + profiles.updateCore("a", CoreProfilePreferences(bodyWeightKg = 81f)) + runCurrent() + + assertNull(viewModel.uiState.value.importedBodyWeightMeasuredAt) + assertEquals(3, externalMeasurements.observationRequests.size) + + bodyWeightRelease.complete(Unit) + runCurrent() + assertEquals(200L, viewModel.uiState.value.importedBodyWeightMeasuredAt) + assertEquals( + listOf(ProfilePreferenceSection.CORE, ProfilePreferenceSection.CORE), + profiles.preferenceUpdateRequests.map(::requestSection), + ) + } + + @Test + fun `measurement attribution clears during Switching`() = runTest { + profiles.seedReadyProfileForTest("a") + profiles.updateCore("a", CoreProfilePreferences(bodyWeightKg = 80f)) + externalMeasurements.upsertMeasurements( + listOf(measurement("a-80", "a", 80.0, 100L)), + ) + val viewModel = createViewModel() + advanceUntilIdle() + assertEquals(100L, viewModel.uiState.value.importedBodyWeightMeasuredAt) + + profiles.emitSwitchingForTest("b") + runCurrent() + + assertNull(viewModel.uiState.value.importedBodyWeightMeasuredAt) + assertIs(viewModel.uiState.value.context) + } + + @Test + fun `non cooperative old measurement cannot overwrite a new generation or profile`() = runTest { + profiles.seedReadyProfileForTest("b") + profiles.updateCore("b", CoreProfilePreferences(bodyWeightKg = 90f)) + profiles.seedReadyProfileForTest("a") + profiles.updateCore("a", CoreProfilePreferences(bodyWeightKg = 80f)) + val oldRelease = CompletableDeferred() + val old = measurement("a-old", "a", 80.0, 100L) + val currentGeneration = measurement("a-current", "a", 81.0, 200L) + val currentProfile = measurement("b-current", "b", 90.0, 300L) + var aObservationCount = 0 + externalMeasurements.observeByTypeOverride = { profileId, _ -> + when (profileId) { + "a" -> if (++aObservationCount == 1) { + nonCooperativeMeasurementFlow(oldRelease, old) + } else { + flowOf(listOf(currentGeneration)) + } + "b" -> flowOf(listOf(currentProfile)) + else -> flowOf(emptyList()) + } + } + val viewModel = createViewModel() + runCurrent() + + profiles.updateCore("a", CoreProfilePreferences(bodyWeightKg = 81f)) + runCurrent() + + assertEquals(2, externalMeasurements.observationRequests.count { it.profileId == "a" }) + assertEquals(200L, viewModel.uiState.value.importedBodyWeightMeasuredAt) + + assertEquals("a", assertIs(viewModel.uiState.value.context).profile.id) + assertEquals( + 81f, + assertIs(viewModel.uiState.value.context) + .preferences.core.value.bodyWeightKg, + ) + assertEquals(200L, viewModel.uiState.value.importedBodyWeightMeasuredAt) + + profiles.emitSwitchingForTest("b") + runCurrent() + profiles.emitReadyForTest("b") + runCurrent() + + assertEquals("b", assertIs(viewModel.uiState.value.context).profile.id) + assertEquals(300L, viewModel.uiState.value.importedBodyWeightMeasuredAt) + + oldRelease.complete(Unit) + runCurrent() + + assertEquals("b", assertIs(viewModel.uiState.value.context).profile.id) + assertEquals(300L, viewModel.uiState.value.importedBodyWeightMeasuredAt) + } + + @Test + fun `adult enable owns local safety and VBT and commits safety first`() = runTest { + profiles.seedReadyProfileForTest("a") + val viewModel = createViewModel() + val events = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.events.toList(events) + } + advanceUntilIdle() + + val token = assertNotNull(viewModel.confirmAdultsOnlyAndEnableVulgar()) + assertEquals( + setOf(ProfilePreferenceSection.LOCAL_SAFETY, ProfilePreferenceSection.VBT), + viewModel.uiState.value.busyPreferenceSections, + ) + advanceUntilIdle() + + assertEquals( + listOf(ProfilePreferenceSection.LOCAL_SAFETY, ProfilePreferenceSection.VBT), + profiles.preferenceUpdateRequests.map(::requestSection), + ) + val safetyRequest = assertIs( + profiles.preferenceUpdateRequests[0], + ) + assertTrue(safetyRequest.value.adultsOnlyConfirmed) + assertTrue(safetyRequest.value.adultsOnlyPrompted) + val vbtRequest = assertIs( + profiles.preferenceUpdateRequests[1], + ) + assertTrue(vbtRequest.value.vulgarModeEnabled) + val success = assertIs(events.single()) + assertEquals(token, success.token) + assertEquals("a", success.profileId) + assertEquals(ProfilePreferenceMutationKind.ADULT_ENABLE, success.kind) + assertEquals( + setOf(ProfilePreferenceSection.LOCAL_SAFETY, ProfilePreferenceSection.VBT), + success.sections, + ) + } + + @Test + fun `adult first write failure commits neither section`() = runTest { + profiles.seedReadyProfileForTest("a") + profiles.updateLocalSafetyFailure = IllegalStateException("safety") + val viewModel = createViewModel() + val events = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.events.toList(events) + } + advanceUntilIdle() + + val token = assertNotNull(viewModel.confirmAdultsOnlyAndEnableVulgar()) + advanceUntilIdle() + + assertEquals( + listOf(ProfilePreferenceSection.LOCAL_SAFETY), + profiles.preferenceUpdateRequests.map(::requestSection), + ) + val failure = assertIs(events.single()) + assertEquals(token, failure.token) + assertEquals(ProfilePreferenceMutationKind.ADULT_ENABLE, failure.kind) + assertTrue(failure.committedSections.isEmpty()) + val ready = assertIs(viewModel.uiState.value.context) + assertFalse(ready.localSafety.adultsOnlyConfirmed) + assertFalse(ready.preferences.vbt.value.vulgarModeEnabled) + } + + @Test + fun `adult second write failure retains confirmed safety and leaves vulgar false`() = runTest { + profiles.seedReadyProfileForTest("a") + profiles.updateVbtFailure = IllegalStateException("vbt") + val viewModel = createViewModel() + val events = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.events.toList(events) + } + advanceUntilIdle() + + val token = assertNotNull(viewModel.confirmAdultsOnlyAndEnableVulgar()) + advanceUntilIdle() + + assertEquals( + listOf(ProfilePreferenceSection.LOCAL_SAFETY, ProfilePreferenceSection.VBT), + profiles.preferenceUpdateRequests.map(::requestSection), + ) + val failure = assertIs(events.single()) + assertEquals(token, failure.token) + assertEquals( + setOf(ProfilePreferenceSection.LOCAL_SAFETY), + failure.committedSections, + ) + val ready = assertIs(viewModel.uiState.value.context) + assertTrue(ready.localSafety.adultsOnlyConfirmed) + assertTrue(ready.localSafety.adultsOnlyPrompted) + assertFalse(ready.preferences.vbt.value.vulgarModeEnabled) + } + + @Test + fun `switch between adult writes never mutates B`() = runTest { + profiles.seedReadyProfileForTest("b") + profiles.seedReadyProfileForTest("a") + profiles.beforePreferenceUpdate = { request -> + if (request is FakeUserProfileRepository.PreferenceUpdateRequest.Vbt) { + profiles.emitSwitchingForTest("b") + profiles.emitReadyForTest("b") + } + } + val viewModel = createViewModel() + val events = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.events.toList(events) + } + advanceUntilIdle() + + val token = assertNotNull(viewModel.confirmAdultsOnlyAndEnableVulgar()) + advanceUntilIdle() + + assertTrue(profiles.preferenceUpdateRequests.all { it.profileId == "a" }) + val bReady = assertIs(profiles.activeProfileContext.value) + assertEquals("b", bReady.profile.id) + assertFalse(bReady.localSafety.adultsOnlyConfirmed) + assertFalse(bReady.preferences.vbt.value.vulgarModeEnabled) + assertTrue(events.none { + it is ProfileUiEvent.PreferenceMutationSucceeded && it.token == token + }) + + profiles.emitReadyForTest("a") + val aReady = assertIs(profiles.activeProfileContext.value) + assertTrue(aReady.localSafety.adultsOnlyConfirmed) + assertFalse(aReady.preferences.vbt.value.vulgarModeEnabled) + } + + @Test + fun `adult cancellation emits no terminal outcome and clears only its owner`() = runTest { + profiles.seedReadyProfileForTest("a") + profiles.updateLocalSafetyFailure = CancellationException("cancel adult") + val ledGate = CompletableDeferred() + profiles.beforePreferenceUpdate = { request -> + if (request is FakeUserProfileRepository.PreferenceUpdateRequest.Led) ledGate.await() + } + val viewModel = createViewModel() + val events = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.events.toList(events) + } + advanceUntilIdle() + + val ledToken = assertNotNull(viewModel.updateLed(LedPreferences(colorScheme = 2))) + val adultToken = assertNotNull(viewModel.confirmAdultsOnlyAndEnableVulgar()) + runCurrent() + + assertEquals( + ledToken, + viewModel.uiState.value.preferenceMutations + .getValue(ProfilePreferenceSection.LED) + .token, + ) + assertFalse(viewModel.uiState.value.preferenceMutations.values.any { it.token == adultToken }) + assertTrue(events.isEmpty()) + + ledGate.complete(Unit) + advanceUntilIdle() + assertTrue(events.none { event -> + (event as? ProfileUiEvent.PreferenceMutationSucceeded)?.token == adultToken || + (event as? ProfileUiEvent.PreferenceUpdateFailed)?.token == adultToken + }) + + val initialYieldToken = assertNotNull( + viewModel.updateCore(CoreProfilePreferences(bodyWeightKg = 80f)), + ) + launch(coroutineRule.dispatcher) { + viewModel.viewModelScope.cancel() + } + runCurrent() + + assertFalse( + viewModel.uiState.value.preferenceMutations.values.any { it.token == initialYieldToken }, + ) + assertTrue(events.none { event -> + (event as? ProfileUiEvent.PreferenceMutationSucceeded)?.token == initialYieldToken || + (event as? ProfileUiEvent.PreferenceUpdateFailed)?.token == initialYieldToken + }) + } + + @Test + fun `adult operation overlaps neither local safety nor VBT writes`() = runTest { + profiles.seedReadyProfileForTest("a") + val gate = CompletableDeferred() + profiles.beforePreferenceUpdate = { request -> + if (request is FakeUserProfileRepository.PreferenceUpdateRequest.LocalSafety) gate.await() + } + val viewModel = createViewModel() + advanceUntilIdle() + + assertNotNull(viewModel.confirmAdultsOnlyAndEnableVulgar()) + assertNull( + viewModel.updateLocalSafety( + ProfileLocalSafetyPreferences(safeWord = "blocked"), + ), + ) + assertNull(viewModel.updateVbt(VbtPreferences(velocityLossThresholdPercent = 25))) + assertNotNull(viewModel.updateLed(LedPreferences(colorScheme = 3))) + + gate.complete(Unit) + advanceUntilIdle() + assertEquals(1, profiles.preferenceUpdateRequests.count { + it is FakeUserProfileRepository.PreferenceUpdateRequest.LocalSafety + }) + assertEquals(1, profiles.preferenceUpdateRequests.count { + it is FakeUserProfileRepository.PreferenceUpdateRequest.Vbt + }) + } + + @Test + fun `adult partial commit retry writes only VBT`() = runTest { + profiles.seedReadyProfileForTest("a") + profiles.updateVbtFailure = IllegalStateException("first vbt") + val viewModel = createViewModel() + val events = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.events.toList(events) + } + advanceUntilIdle() + + assertNotNull(viewModel.confirmAdultsOnlyAndEnableVulgar()) + advanceUntilIdle() + assertTrue( + assertIs(viewModel.uiState.value.context) + .localSafety.adultsOnlyConfirmed, + ) + + profiles.preferenceUpdateRequests.clear() + profiles.updateVbtFailure = null + events.clear() + val retryToken = assertNotNull(viewModel.confirmAdultsOnlyAndEnableVulgar()) + advanceUntilIdle() + + assertEquals( + listOf(ProfilePreferenceSection.VBT), + profiles.preferenceUpdateRequests.map(::requestSection), + ) + val success = assertIs(events.single()) + assertEquals(retryToken, success.token) + assertEquals(ProfilePreferenceMutationKind.ADULT_ENABLE, success.kind) + assertEquals(setOf(ProfilePreferenceSection.VBT), success.sections) + assertTrue( + assertIs(viewModel.uiState.value.context) + .preferences.vbt.value.vulgarModeEnabled, + ) + } + + @Test + fun `disco unlock succeeds only after authoritative matching commit`() = runTest { + profiles.seedReadyProfileForTest("a") + val gate = CompletableDeferred() + profiles.beforePreferenceUpdate = { request -> + if (request is FakeUserProfileRepository.PreferenceUpdateRequest.Led) gate.await() + } + val viewModel = createViewModel() + val events = mutableListOf() + val unlockedAtEvent = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.events.collect { event -> + events += event + if (event is ProfileUiEvent.PreferenceMutationSucceeded) { + unlockedAtEvent += assertIs( + viewModel.uiState.value.context, + ).preferences.led.value.discoModeUnlocked + } + } + } + advanceUntilIdle() + + val token = assertNotNull(viewModel.unlockDiscoMode()) + runCurrent() + assertTrue(events.isEmpty()) + assertFalse( + assertIs(viewModel.uiState.value.context) + .preferences.led.value.discoModeUnlocked, + ) + + gate.complete(Unit) + advanceUntilIdle() + + assertEquals(listOf(true), unlockedAtEvent) + val success = assertIs(events.single()) + assertEquals(token, success.token) + assertEquals("a", success.profileId) + assertEquals(ProfilePreferenceMutationKind.DISCO_UNLOCK, success.kind) + assertEquals(setOf(ProfilePreferenceSection.LED), success.sections) + assertTrue(viewModel.uiState.value.busyPreferenceSections.isEmpty()) + } + + @Test + fun `dominatrix success emits only after the authoritative unlock commit`() = runTest { + profiles.seedReadyProfileForTest("a") + profiles.updateLocalSafety( + "a", + ProfileLocalSafetyPreferences( + adultsOnlyConfirmed = true, + adultsOnlyPrompted = true, + ), + ) + profiles.updateVbt( + "a", + VbtPreferences( + enabled = true, + verbalEncouragementEnabled = true, + vulgarModeEnabled = true, + ), + ) + profiles.preferenceUpdateRequests.clear() + val gate = CompletableDeferred() + profiles.beforePreferenceUpdate = { request -> + if (request is FakeUserProfileRepository.PreferenceUpdateRequest.Vbt) gate.await() + } + val viewModel = createViewModel() + val events = mutableListOf() + val unlockedAtEvent = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.events.collect { event -> + events += event + if (event is ProfileUiEvent.PreferenceMutationSucceeded) { + unlockedAtEvent += assertIs( + viewModel.uiState.value.context, + ).preferences.vbt.value.dominatrixModeUnlocked + } + } + } + advanceUntilIdle() + + val token = assertNotNull(viewModel.unlockDominatrixMode()) + runCurrent() + assertTrue(events.isEmpty()) + assertFalse( + assertIs(viewModel.uiState.value.context) + .preferences.vbt.value.dominatrixModeUnlocked, + ) + + gate.complete(Unit) + advanceUntilIdle() + + assertEquals(listOf(true), unlockedAtEvent) + val success = assertIs(events.single()) + assertEquals(token, success.token) + assertEquals(ProfilePreferenceMutationKind.DOMINATRIX_UNLOCK, success.kind) + assertEquals(setOf(ProfilePreferenceSection.VBT), success.sections) + } + + @Test + fun `dominatrix failure and switch produce no stale success`() = runTest { + profiles.seedReadyProfileForTest("b") + profiles.seedReadyProfileForTest("a") + val viewModel = createViewModel() + val events = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.events.toList(events) + } + advanceUntilIdle() + + assertNull(viewModel.unlockDominatrixMode()) + runCurrent() + assertTrue(profiles.preferenceUpdateRequests.isEmpty()) + assertTrue(events.isEmpty()) + + profiles.updateLocalSafety( + "a", + ProfileLocalSafetyPreferences( + adultsOnlyConfirmed = true, + adultsOnlyPrompted = true, + ), + ) + profiles.updateVbt( + "a", + VbtPreferences( + enabled = false, + verbalEncouragementEnabled = true, + vulgarModeEnabled = true, + ), + ) + advanceUntilIdle() + profiles.preferenceUpdateRequests.clear() + assertNull(viewModel.unlockDominatrixMode()) + + profiles.updateVbt( + "a", + assertIs(profiles.activeProfileContext.value) + .preferences.vbt.value.copy(enabled = true), + ) + advanceUntilIdle() + profiles.preferenceUpdateRequests.clear() + profiles.updateVbtFailure = IllegalStateException("dominatrix write") + + val failureToken = assertNotNull(viewModel.unlockDominatrixMode()) + advanceUntilIdle() + + val ordinaryFailure = assertIs(events.single()) + assertEquals(failureToken, ordinaryFailure.token) + assertEquals("a", ordinaryFailure.profileId) + assertEquals(ProfilePreferenceMutationKind.DOMINATRIX_UNLOCK, ordinaryFailure.kind) + assertEquals(setOf(ProfilePreferenceSection.VBT), ordinaryFailure.sections) + assertTrue(ordinaryFailure.committedSections.isEmpty()) + assertFalse( + assertIs(viewModel.uiState.value.context) + .preferences.vbt.value.dominatrixModeUnlocked, + ) + + profiles.updateVbtFailure = null + profiles.preferenceUpdateRequests.clear() + events.clear() + profiles.beforePreferenceUpdate = { request -> + if (request is FakeUserProfileRepository.PreferenceUpdateRequest.Vbt) { + profiles.emitSwitchingForTest("b") + profiles.emitReadyForTest("b") + } + } + + val staleToken = assertNotNull(viewModel.unlockDominatrixMode()) + advanceUntilIdle() + + assertTrue(events.none { + it is ProfileUiEvent.PreferenceMutationSucceeded && it.token == staleToken + }) + val ready = assertIs(profiles.activeProfileContext.value) + assertEquals("b", ready.profile.id) + assertFalse(ready.preferences.vbt.value.dominatrixModeUnlocked) + assertEquals( + listOf(ProfilePreferenceSection.VBT), + profiles.preferenceUpdateRequests.map(::requestSection), + ) + } + + @Test + fun `decline clears adult modes before recording prompted unconfirmed`() = runTest { + profiles.seedReadyProfileForTest("a") + profiles.updateVbt( + "a", + VbtPreferences( + verbalEncouragementEnabled = true, + vulgarModeEnabled = true, + dominatrixModeUnlocked = true, + dominatrixModeActive = true, + ), + ) + profiles.preferenceUpdateRequests.clear() + val viewModel = createViewModel() + advanceUntilIdle() + + assertNotNull(viewModel.declineAdultsOnly()) + advanceUntilIdle() + val declined = assertIs(viewModel.uiState.value.context) + assertEquals( + listOf(ProfilePreferenceSection.VBT, ProfilePreferenceSection.LOCAL_SAFETY), + profiles.preferenceUpdateRequests.map(::requestSection), + ) + assertTrue(declined.localSafety.adultsOnlyPrompted) + assertFalse(declined.localSafety.adultsOnlyConfirmed) + assertFalse(declined.preferences.vbt.value.vulgarModeEnabled) + assertFalse(declined.preferences.vbt.value.dominatrixModeActive) + assertTrue(declined.preferences.vbt.value.dominatrixModeUnlocked) + + profiles.preferenceUpdateRequests.clear() + val retryToken = assertNotNull(viewModel.confirmAdultsOnlyAndEnableVulgar()) + advanceUntilIdle() + + assertTrue(retryToken > 0) + assertEquals( + listOf(ProfilePreferenceSection.LOCAL_SAFETY, ProfilePreferenceSection.VBT), + profiles.preferenceUpdateRequests.map(::requestSection), + ) + val enabled = assertIs(viewModel.uiState.value.context) + assertTrue(enabled.localSafety.adultsOnlyPrompted) + assertTrue(enabled.localSafety.adultsOnlyConfirmed) + assertTrue(enabled.preferences.vbt.value.vulgarModeEnabled) + } + + @Test + fun `decline VBT failure leaves safety untouched and retry remains fail closed`() = runTest { + profiles.seedReadyProfileForTest("a") + profiles.updateVbtFailure = IllegalStateException("vbt") + val viewModel = createViewModel() + val events = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.events.toList(events) + } + advanceUntilIdle() + + val failedToken = assertNotNull(viewModel.declineAdultsOnly()) + advanceUntilIdle() + + assertEquals( + listOf(ProfilePreferenceSection.VBT), + profiles.preferenceUpdateRequests.map(::requestSection), + ) + val failure = assertIs(events.single()) + assertEquals(failedToken, failure.token) + assertTrue(failure.committedSections.isEmpty()) + assertFalse( + assertIs(viewModel.uiState.value.context) + .localSafety.adultsOnlyPrompted, + ) + + profiles.preferenceUpdateRequests.clear() + profiles.updateVbtFailure = null + events.clear() + assertNotNull(viewModel.declineAdultsOnly()) + advanceUntilIdle() + + assertEquals( + listOf(ProfilePreferenceSection.VBT, ProfilePreferenceSection.LOCAL_SAFETY), + profiles.preferenceUpdateRequests.map(::requestSection), + ) + val ready = assertIs(viewModel.uiState.value.context) + assertTrue(ready.localSafety.adultsOnlyPrompted) + assertFalse(ready.preferences.vbt.value.vulgarModeEnabled) + assertFalse(ready.preferences.vbt.value.dominatrixModeActive) + } + + @Test + fun `decline safety failure retains the committed fail closed VBT state`() = runTest { + profiles.seedReadyProfileForTest("a") + profiles.updateVbt( + "a", + VbtPreferences( + verbalEncouragementEnabled = true, + vulgarModeEnabled = true, + dominatrixModeUnlocked = true, + dominatrixModeActive = true, + ), + ) + profiles.preferenceUpdateRequests.clear() + profiles.updateLocalSafetyFailure = IllegalStateException("safety") + val viewModel = createViewModel() + val events = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.events.toList(events) + } + advanceUntilIdle() + + assertNotNull(viewModel.declineAdultsOnly()) + advanceUntilIdle() + + val failure = assertIs(events.single()) + assertEquals(setOf(ProfilePreferenceSection.VBT), failure.committedSections) + val ready = assertIs(viewModel.uiState.value.context) + assertFalse(ready.preferences.vbt.value.vulgarModeEnabled) + assertFalse(ready.preferences.vbt.value.dominatrixModeActive) + assertTrue(ready.preferences.vbt.value.dominatrixModeUnlocked) + assertFalse(ready.localSafety.adultsOnlyPrompted) + } + + private data class PreferenceSuccessSnapshot( + val event: ProfileUiEvent.PreferenceMutationSucceeded, + val uiState: ProfileUiState, + val repositoryReady: ActiveProfileContext.Ready, + ) + + private fun requestSection( + request: FakeUserProfileRepository.PreferenceUpdateRequest, + ): ProfilePreferenceSection = when (request) { + is FakeUserProfileRepository.PreferenceUpdateRequest.Core -> ProfilePreferenceSection.CORE + is FakeUserProfileRepository.PreferenceUpdateRequest.Rack -> ProfilePreferenceSection.RACK + is FakeUserProfileRepository.PreferenceUpdateRequest.Workout -> ProfilePreferenceSection.WORKOUT + is FakeUserProfileRepository.PreferenceUpdateRequest.Led -> ProfilePreferenceSection.LED + is FakeUserProfileRepository.PreferenceUpdateRequest.Vbt -> ProfilePreferenceSection.VBT + is FakeUserProfileRepository.PreferenceUpdateRequest.LocalSafety -> + ProfilePreferenceSection.LOCAL_SAFETY + } + + private fun measurement( + externalId: String, + profileId: String, + value: Double, + measuredAt: Long, + ) = ExternalBodyMeasurement( + externalId = externalId, + provider = IntegrationProvider.APPLE_HEALTH, + measurementType = "weight", + value = value, + unit = "kg", + measuredAt = measuredAt, + profileId = profileId, + ) + + private fun nonCooperativeMeasurementFlow( + release: CompletableDeferred, + measurement: ExternalBodyMeasurement, + ): Flow> = object : Flow> { + override suspend fun collect(collector: FlowCollector>) { + withContext(NonCancellable) { + release.await() + collector.emit(listOf(measurement)) + } + } + } + + private fun createViewModel() = ProfileViewModel( + profiles = profiles, + exercises = exercises, + workouts = workouts, + personalRecords = personalRecords, + resolveCurrentOneRepMax = ResolveCurrentOneRepMaxUseCase( + velocityRepository = velocity, + assessmentRepository = assessments, + workoutRepository = workouts, + ), + externalMeasurements = externalMeasurements, + ) + + private fun exercise(id: String): Exercise = Exercise( + name = id.replaceFirstChar { it.uppercase() }, + muscleGroup = "Other", + id = id, + ) + + private fun session( + id: String, + profileId: String, + exerciseId: String, + timestamp: Long, + weightPerCableKg: Float = 40f, + workingReps: Int = 5, + ) = WorkoutSession( + id = id, + profileId = profileId, + exerciseId = exerciseId, + exerciseName = exerciseId, + timestamp = timestamp, + weightPerCableKg = weightPerCableKg, + workingReps = workingReps, + totalReps = workingReps, + ) + + private fun record( + id: Long, + profileId: String, + exerciseId: String, + prType: PRType, + weightPerCableKg: Float, + oneRepMax: Float, + volume: Float, + workoutMode: String = "Old School", + ) = PersonalRecord( + id = id, + profileId = profileId, + exerciseId = exerciseId, + exerciseName = exerciseId, + weightPerCableKg = weightPerCableKg, + reps = 5, + oneRepMax = oneRepMax, + timestamp = id, + workoutMode = workoutMode, + prType = prType, + volume = volume, + ) +} diff --git a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/util/BackupJsonNavigatorTest.kt b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/util/BackupJsonNavigatorTest.kt index a8897a5a4..0b28bfe8d 100644 --- a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/util/BackupJsonNavigatorTest.kt +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/util/BackupJsonNavigatorTest.kt @@ -1,10 +1,28 @@ package com.devil.phoenixproject.util -import com.devil.phoenixproject.data.repository.SettingsEquipmentRackRepository +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver +import com.devil.phoenixproject.data.preferences.SettingsProfileLocalSafetyStore +import com.devil.phoenixproject.data.repository.ProfilePreferencesRepository +import com.devil.phoenixproject.data.repository.SqlDelightGamificationRepository +import com.devil.phoenixproject.data.repository.SqlDelightProfilePreferencesRepository +import com.devil.phoenixproject.data.repository.SqlDelightUserProfileRepository import com.devil.phoenixproject.data.repository.SqlDelightWorkoutRepository +import com.devil.phoenixproject.data.repository.UserProfileRepository +import com.devil.phoenixproject.database.VitruvianDatabase +import com.devil.phoenixproject.domain.model.CoreProfilePreferences import com.devil.phoenixproject.domain.model.Exercise +import com.devil.phoenixproject.domain.model.LedPreferences +import com.devil.phoenixproject.domain.model.ProfileLocalSafetyPreferences +import com.devil.phoenixproject.domain.model.RackItem +import com.devil.phoenixproject.domain.model.RackItemBehavior +import com.devil.phoenixproject.domain.model.RackItemCategory +import com.devil.phoenixproject.domain.model.RackPreferences import com.devil.phoenixproject.domain.model.Routine import com.devil.phoenixproject.domain.model.RoutineExercise +import com.devil.phoenixproject.domain.model.VbtPreferences +import com.devil.phoenixproject.domain.model.WeightUnit +import com.devil.phoenixproject.domain.model.WorkoutPreferences import com.devil.phoenixproject.domain.model.WorkoutSession import com.devil.phoenixproject.testutil.FakeExerciseRepository import com.devil.phoenixproject.testutil.createTestDatabase @@ -12,9 +30,20 @@ import com.russhwolf.settings.MapSettings import java.io.File import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertSame import kotlin.test.assertTrue import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put import org.junit.Test // ============================================================================= @@ -406,9 +435,15 @@ class StreamingImportRoundTripTest { private class TestDataBackupManager( database: com.devil.phoenixproject.database.VitruvianDatabase, + val profilePreferencesRepository: ProfilePreferencesRepository = SqlDelightProfilePreferencesRepository(database), + val userProfileRepository: UserProfileRepository = createTestUserProfileRepository( + database, + profilePreferencesRepository, + ), ) : BaseDataBackupManager( database, - SettingsEquipmentRackRepository(MapSettings()), + profilePreferencesRepository, + userProfileRepository, ) { override fun createBackupWriter(): BackupJsonWriter { @@ -447,6 +482,16 @@ class StreamingImportRoundTripTest { /** Public wrapper exposing the protected [importFromStream] for testing. */ suspend fun importFromStreamPublic(source: BackupStreamSource): Result = importFromStream(source) + + suspend fun importFromStringStreaming(value: String): Result { + val source = StringBackupStreamSource(value) + source.open() + return try { + importFromStreamPublic(source) + } finally { + source.close() + } + } } @Test @@ -482,11 +527,650 @@ class StreamingImportRoundTripTest { } @Test - fun `streaming import round-trip preserves all entities`() = runTest { + fun `streaming defers profile state until final root version regardless of field order`() = runTest { + val v5Fixture = preferenceFixture() + seedProfiles(v5Fixture) + val v5Payload = adversarialBackup( + finalVersion = 5, + identities = listOf(profileBackup(PROFILE_A, isActive = false)), + preferences = listOf( + preferenceEntry( + PROFILE_A, + core = jsonElement(CoreProfilePreferences(83f, WeightUnit.KG, 2.5f)), + rack = jsonElement(RackPreferences(items = listOf(rackItem("v5", "V5", 8f)))), + ), + ), + legacyRack = JsonArray(emptyList()), + ) + + val v5Result = v5Fixture.manager.importFromStringStreaming(v5Payload) + + assertTrue(v5Result.isSuccess, v5Result.exceptionOrNull()?.toString()) + assertEquals(83f, v5Fixture.preferences.get(PROFILE_A).core.value.bodyWeightKg) + assertEquals( + listOf("v5"), + v5Fixture.preferences.get(PROFILE_A).rack.value.items.map { it.id }, + "final v5 preference rack must win while legacy empty rack is ignored", + ) + + val v4Fixture = preferenceFixture() + seedProfiles(v4Fixture) + val v4Payload = adversarialBackup( + finalVersion = 4, + identities = listOf(profileBackup(PROFILE_A)), + preferences = listOf( + preferenceEntry( + PROFILE_A, + core = jsonElement(CoreProfilePreferences(120f, WeightUnit.LB, 5f)), + ), + ), + legacyRack = JsonArray(emptyList()), + ) + + val v4Result = v4Fixture.manager.importFromStringStreaming(v4Payload) + + assertTrue(v4Result.isSuccess, v4Result.exceptionOrNull()?.toString()) + assertEquals(70f, v4Fixture.preferences.get(PROFILE_A).core.value.bodyWeightKg) + assertTrue(v4Fixture.preferences.get(PROFILE_A).rack.value.items.isEmpty()) + } + + @Test + fun `streaming v4 distinguishes missing empty invalid and valid rack payloads`() = runTest { + suspend fun restore(present: Boolean, raw: JsonElement): Pair { + val fixture = preferenceFixture() + seedProfiles(fixture) + val data = buildJsonObject { + put("userProfiles", testJson.encodeToJsonElement(listOf(profileBackup(PROFILE_A)))) + if (present) put("equipmentRackItems", raw) + } + val payload = buildJsonObject { + put("data", data) + put("version", 4) + put("exportedAt", "2026-07-12T00:00:00Z") + put("appVersion", "test") + }.toString() + return fixture to fixture.manager.importFromStringStreaming(payload).getOrThrow() + } + + val missing = restore(false, JsonNull).first + assertEquals(listOf("a-existing", "shared"), missing.preferences.get(PROFILE_A).rack.value.items.map { it.id }) + + listOf( + JsonNull, + JsonPrimitive(7), + JsonArray(listOf(buildJsonObject { put("id", "malformed") })), + ).forEach { invalid -> + val restored = restore(true, invalid) + assertEquals(1, restored.second.entitiesWithErrors) + assertEquals( + listOf("a-existing", "shared"), + restored.first.preferences.get(PROFILE_A).rack.value.items.map { it.id }, + ) + } + + assertTrue(restore(true, JsonArray(emptyList())).first.preferences.get(PROFILE_A).rack.value.items.isEmpty()) + + val valid = restore( + true, + jsonElement(listOf( + rackItem("shared", "Imported", 9f), + rackItem("new", "New", 3f), + )), + ).first.preferences.get(PROFILE_A).rack.value.items + assertEquals(listOf("a-existing", "shared", "new"), valid.map { it.id }) + assertEquals("Imported", valid[1].name) + } + + @Test + fun `buffered and streaming preference restores have typed and metadata parity`() = runTest { + val buffered = preferenceFixture() + val streamed = preferenceFixture() + seedProfiles(buffered) + seedProfiles(streamed) + val payload = standardBackup( + version = 5, + identities = listOf(profileBackup(PROFILE_A), profileBackup(PROFILE_B)), + preferences = listOf( + preferenceEntry( + PROFILE_A, + core = jsonElement(CoreProfilePreferences(88f, WeightUnit.KG, 1.25f)), + rack = jsonElement(RackPreferences(items = listOf(rackItem("new-a", "New A", 3f)))), + workout = jsonElement(WorkoutPreferences(stopAtTop = true, summaryCountdownSeconds = 20)), + led = jsonElement(LedPreferences(colorScheme = 9, discoModeUnlocked = true)), + vbt = jsonElement(VbtPreferences(enabled = false, velocityLossThresholdPercent = 50)), + ), + preferenceEntry( + PROFILE_B, + core = jsonElement(CoreProfilePreferences(99f, WeightUnit.LB, 5f)), + rack = jsonElement(RackPreferences(items = listOf(rackItem("new-b", "New B", 6f)))), + workout = jsonElement(WorkoutPreferences(beepsEnabled = false, summaryCountdownSeconds = 25)), + led = jsonElement(LedPreferences(colorScheme = 10)), + vbt = jsonElement(VbtPreferences(enabled = true, velocityLossThresholdPercent = 35)), + ), + ), + ) + + val bufferedResult = buffered.manager.importFromJson(payload) + val streamedResult = streamed.manager.importFromStringStreaming(payload) + + assertTrue(bufferedResult.isSuccess, bufferedResult.exceptionOrNull()?.toString()) + assertTrue(streamedResult.isSuccess, streamedResult.exceptionOrNull()?.toString()) + assertEquals(bufferedResult.getOrThrow().entitiesWithErrors, streamedResult.getOrThrow().entitiesWithErrors) + listOf(PROFILE_A, PROFILE_B).forEach { profileId -> + val left = buffered.preferences.get(profileId) + val right = streamed.preferences.get(profileId) + assertEquals(left.core.value, right.core.value) + assertEquals(left.rack.value, right.rack.value) + assertEquals(left.workout.value, right.workout.value) + assertEquals(left.led.value, right.led.value) + assertEquals(left.vbt.value, right.vbt.value) + assertEquals(left.core.metadata.copy(updatedAt = 0), right.core.metadata.copy(updatedAt = 0)) + assertEquals(left.rack.metadata.copy(updatedAt = 0), right.rack.metadata.copy(updatedAt = 0)) + assertEquals(left.workout.metadata.copy(updatedAt = 0), right.workout.metadata.copy(updatedAt = 0)) + assertEquals(left.led.metadata.copy(updatedAt = 0), right.led.metadata.copy(updatedAt = 0)) + assertEquals(left.vbt.metadata.copy(updatedAt = 0), right.vbt.metadata.copy(updatedAt = 0)) + } + } + + @Test + fun `streaming active flags never switch target and post-identity failure normalizes then reconciles`() = runTest { + listOf( + listOf(false, false), + listOf(false, true), + listOf(true, true), + ).forEach { flags -> + val fixture = preferenceFixture() + seedProfiles(fixture) + val payload = standardBackup( + version = 5, + identities = listOf( + profileBackup(PROFILE_A, flags[0]), + profileBackup(PROFILE_B, flags[1]), + ), + ) + assertTrue(fixture.manager.importFromStringStreaming(payload).isSuccess) + val profiles = fixture.database.vitruvianDatabaseQueries.getAllProfiles().executeAsList() + assertEquals(PROFILE_A, profiles.single { it.isActive == 1L }.id) + assertEquals(1, fixture.recordingUserProfiles.reconcileCalls) + } + + val failed = preferenceFixture() + seedProfiles(failed) + val malformedAfterIdentityCommit = """ + { + "data": { + "userProfiles": [ + {"id":"profile-b","name":"Profile B","colorIndex":0,"createdAt":200,"isActive":true} + ], + "workoutSessions": [} + }, + "version": 5, + "exportedAt": "x", + "appVersion": "x" + } + """.trimIndent() + + val result = failed.manager.importFromStringStreaming(malformedAfterIdentityCommit) + + assertTrue(result.isFailure) + val profiles = failed.database.vitruvianDatabaseQueries.getAllProfiles().executeAsList() + assertEquals(PROFILE_A, profiles.single { it.isActive == 1L }.id) + assertEquals(1, failed.recordingUserProfiles.reconcileCalls) + } + + @Test + fun `streaming legacy rows use first represented fallback when no prior active or default exists`() = runTest { + val fixture = preferenceFixture() + fixture.driver.execute(null, "DELETE FROM UserProfile WHERE id = 'default'", 0) + val represented = "first-represented" + val session = WorkoutSessionBackup( + id = "stream-legacy-fallback", + timestamp = 1L, + mode = "Old School", + targetReps = 1, + weightPerCableKg = 1f, + progressionKg = 0f, + duration = 1L, + totalReps = 1, + warmupReps = 0, + workingReps = 1, + isJustLift = false, + stopAtTop = false, + profileId = null, + ) + val payload = buildJsonObject { + put("data", buildJsonObject { + put("workoutSessions", JsonArray(listOf(testJson.encodeToJsonElement(session)))) + put("userProfiles", testJson.encodeToJsonElement(listOf(profileBackup(represented)))) + }) + put("version", 5) + put("exportedAt", "x") + put("appVersion", "x") + }.toString() + + val result = fixture.manager.importFromStringStreaming(payload) + + assertTrue(result.isSuccess, result.exceptionOrNull()?.toString()) + assertEquals( + represented, + fixture.database.vitruvianDatabaseQueries + .selectSessionById(session.id) + .executeAsOne() + .profile_id, + ) + assertEquals(represented, fixture.database.vitruvianDatabaseQueries.getActiveProfile().executeAsOne().id) + } + + @Test + fun `streaming absent explicit owner adopts normalized target instead of provisional default`() = runTest { + val fixture = preferenceFixture() + val session = WorkoutSessionBackup( + id = "stream-explicit-adoption", + timestamp = 1L, + mode = "Old School", + targetReps = 1, + weightPerCableKg = 1f, + progressionKg = 0f, + duration = 1L, + totalReps = 1, + warmupReps = 0, + workingReps = 1, + isJustLift = false, + stopAtTop = false, + profileId = "original-owner", + ) + val representedSession = session.copy(id = "stream-explicit-represented") + fun payload(rows: List, identities: List) = buildJsonObject { + put("data", buildJsonObject { + put("workoutSessions", testJson.encodeToJsonElement(rows)) + put("userProfiles", testJson.encodeToJsonElement(identities)) + }) + put("version", 5) + put("exportedAt", "x") + put("appVersion", "x") + }.toString() + + assertTrue( + fixture.manager.importFromStringStreaming( + payload(listOf(session, representedSession), emptyList()), + ).isSuccess, + ) + fixture.driver.execute(null, "DELETE FROM UserProfile WHERE id = 'default'", 0) + + val result = fixture.manager.importFromStringStreaming( + payload( + listOf( + session.copy(profileId = "default"), + representedSession.copy(profileId = "first-represented"), + ), + listOf(profileBackup("first-represented")), + ), + ) + + assertTrue(result.isSuccess, result.exceptionOrNull()?.toString()) + assertEquals( + "first-represented", + fixture.database.vitruvianDatabaseQueries.selectSessionById(session.id).executeAsOne().profile_id, + ) + assertEquals( + "first-represented", + fixture.database.vitruvianDatabaseQueries + .selectSessionById(representedSession.id) + .executeAsOne() + .profile_id, + ) + assertEquals( + "first-represented", + fixture.database.vitruvianDatabaseQueries.getActiveProfile().executeAsOne().id, + ) + } + + @Test + fun `streaming session import adopts explicit owner when its profile is absent`() = runTest { + val fixture = preferenceFixture() + val activeProfileId = fixture.database.vitruvianDatabaseQueries.getActiveProfile().executeAsOne().id + val session = WorkoutSessionBackup( + id = "stream-missing-profile-session", + timestamp = 1L, + mode = "Old School", + targetReps = 1, + weightPerCableKg = 1f, + progressionKg = 0f, + duration = 1L, + totalReps = 1, + warmupReps = 0, + workingReps = 1, + isJustLift = false, + stopAtTop = false, + profileId = "deleted-source-profile", + ) + val payload = buildJsonObject { + put("data", buildJsonObject { + put("workoutSessions", testJson.encodeToJsonElement(listOf(session))) + }) + put("version", 5) + put("exportedAt", "2026-07-15T00:00:00Z") + put("appVersion", "test") + }.toString() + + val result = fixture.manager.importFromStringStreaming(payload) + + assertTrue(result.isSuccess, result.exceptionOrNull()?.toString()) + assertEquals( + activeProfileId, + fixture.database.vitruvianDatabaseQueries.selectSessionById(session.id).executeAsOne().profile_id, + ) + } + + @Test + fun `streaming v1 through v3 ignore supplied preferences and legacy rack and preserve local safety`() = runTest { + for (version in 1..3) { + val fixture = preferenceFixture() + seedProfiles(fixture) + val localSafety = ProfileLocalSafetyPreferences( + safeWord = "streaming-local-secret-$version", + safeWordCalibrated = true, + adultsOnlyConfirmed = true, + adultsOnlyPrompted = true, + ) + fixture.safetyStore.write(PROFILE_A, localSafety) + val before = fixture.preferences.get(PROFILE_A) + val payload = buildJsonObject { + put("version", version) + put("exportedAt", "x") + put("appVersion", "x") + put("data", buildJsonObject { + put("userProfiles", testJson.encodeToJsonElement(listOf(profileBackup(PROFILE_A)))) + put("profilePreferences", JsonArray(listOf( + preferenceEntry( + PROFILE_A, + core = jsonElement(CoreProfilePreferences(120f, WeightUnit.LB, 10f)), + rack = jsonElement(RackPreferences()), + ), + ))) + put("equipmentRackItems", JsonArray(emptyList())) + }) + }.toString() + + val result = fixture.manager.importFromStringStreaming(payload) + + assertTrue(result.isSuccess, "v$version: ${result.exceptionOrNull()}") + val after = fixture.preferences.get(PROFILE_A) + assertEquals(before.core.value, after.core.value, "v$version core") + assertEquals(before.rack.value, after.rack.value, "v$version rack") + assertEquals(localSafety, fixture.safetyStore.read(PROFILE_A), "v$version local safety") + } + } + + @Test + fun `streaming v6 restores known fields ignores unknown fields and preserves local safety`() = runTest { + val fixture = preferenceFixture() + seedProfiles(fixture) + val localSafety = ProfileLocalSafetyPreferences("v6-stream-secret", true, true, true) + fixture.safetyStore.write(PROFILE_A, localSafety) + val entry = buildJsonObject { + put("profileId", PROFILE_A) + put("core", buildJsonObject { + put("bodyWeightKg", 88f) + put("weightUnit", "KG") + put("weightIncrement", 2.5f) + put("futureSectionField", JsonArray(listOf(JsonPrimitive(1)))) + }) + put("futureEntryField", buildJsonObject { put("ignored", true) }) + } + val payload = buildJsonObject { + put("futureRootField", buildJsonObject { put("ignored", true) }) + put("data", buildJsonObject { + put("futureDataField", JsonPrimitive("ignored")) + put("profilePreferences", JsonArray(listOf(entry))) + put("equipmentRackItems", JsonArray(emptyList())) + put("userProfiles", testJson.encodeToJsonElement(listOf(profileBackup(PROFILE_A)))) + }) + put("version", 6) + put("exportedAt", "x") + put("appVersion", "x") + }.toString() + + val result = fixture.manager.importFromStringStreaming(payload) + + assertTrue(result.isSuccess, result.exceptionOrNull()?.toString()) + assertEquals(0, result.getOrThrow().entitiesWithErrors) + assertEquals(88f, fixture.preferences.get(PROFILE_A).core.value.bodyWeightKg) + assertEquals( + listOf("a-existing", "shared"), + fixture.preferences.get(PROFILE_A).rack.value.items.map { it.id }, + "v6 must ignore the legacy rack", + ) + assertEquals(localSafety, fixture.safetyStore.read(PROFILE_A)) + } + + @Test + fun `streaming preference failure remains primary and reconcile failure is suppressed once`() = runTest { + val restoreFailure = IllegalStateException("streaming preference storage failed") + val reconcileFailure = IllegalArgumentException("streaming reconcile failed") + val fixture = preferenceFixture( + preferenceDecorator = { delegate -> + FaultingProfilePreferencesRepository(delegate, restoreFailure) + }, + reconciliationFailure = reconcileFailure, + ) + val payload = standardBackup( + version = 5, + identities = listOf(profileBackup("default")), + preferences = listOf( + preferenceEntry( + "default", + core = jsonElement(CoreProfilePreferences(80f, WeightUnit.KG, 2.5f)), + ), + ), + ) + + val result = fixture.manager.importFromStringStreaming(payload) + + assertTrue(result.isFailure) + assertSame(restoreFailure, result.exceptionOrNull()) + assertTrue(result.exceptionOrNull()!!.suppressed.any { it.message == reconcileFailure.message }) + assertEquals(1, fixture.recordingUserProfiles.reconcileCalls) + } + + private fun preferenceFixture( + preferenceDecorator: (ProfilePreferencesRepository) -> ProfilePreferencesRepository = { it }, + reconciliationFailure: Throwable? = null, + ): PreferenceFixture { + val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) + VitruvianDatabase.Schema.create(driver) + val database = VitruvianDatabase(driver) + val preferences = preferenceDecorator(SqlDelightProfilePreferencesRepository(database)) + val safetyStore = SettingsProfileLocalSafetyStore(MapSettings()) + val realUserProfiles = SqlDelightUserProfileRepository( + database = database, + profilePreferencesRepository = preferences, + profileLocalSafetyStore = safetyStore, + gamificationRepository = SqlDelightGamificationRepository(database), + ) + database.vitruvianDatabaseQueries.seedMissingProfilePreferences() + val recording = RecordingUserProfileRepository(realUserProfiles, reconciliationFailure) + return PreferenceFixture( + driver = driver, + database = database, + preferences = preferences, + safetyStore = safetyStore, + recordingUserProfiles = recording, + manager = TestDataBackupManager(database, preferences, recording), + ) + } + + private suspend fun seedProfiles(fixture: PreferenceFixture) { + insertProfile(fixture.database, PROFILE_A, 100L) + insertProfile(fixture.database, PROFILE_B, 200L) + fixture.database.vitruvianDatabaseQueries.setActiveProfile(PROFILE_A) + fixture.preferences.updateCore(PROFILE_A, CoreProfilePreferences(70f, WeightUnit.KG, 2.5f), 10L) + fixture.preferences.updateRack( + PROFILE_A, + RackPreferences(items = listOf( + rackItem("a-existing", "A existing", 1f), + rackItem("shared", "A shared", 2f), + )), + 11L, + ) + fixture.preferences.updateWorkout( + PROFILE_A, + WorkoutPreferences(stopAtTop = true, summaryCountdownSeconds = 5), + 12L, + ) + fixture.preferences.updateLed(PROFILE_A, LedPreferences(colorScheme = 2), 13L) + fixture.preferences.updateVbt(PROFILE_A, VbtPreferences(enabled = false, velocityLossThresholdPercent = 30), 14L) + + fixture.preferences.updateCore(PROFILE_B, CoreProfilePreferences(90f, WeightUnit.LB, 5f), 20L) + fixture.preferences.updateRack( + PROFILE_B, + RackPreferences(items = listOf( + rackItem("b-existing", "B existing", 4f), + rackItem("shared", "B shared", 5f), + )), + 21L, + ) + fixture.preferences.updateWorkout( + PROFILE_B, + WorkoutPreferences(beepsEnabled = false, summaryCountdownSeconds = 15), + 22L, + ) + fixture.preferences.updateLed(PROFILE_B, LedPreferences(colorScheme = 7), 23L) + fixture.preferences.updateVbt(PROFILE_B, VbtPreferences(enabled = true, velocityLossThresholdPercent = 40), 24L) + } + + private fun insertProfile(database: VitruvianDatabase, id: String, createdAt: Long) { + if (database.vitruvianDatabaseQueries.getProfileById(id).executeAsOneOrNull() == null) { + database.vitruvianDatabaseQueries.insertProfile(id, id, 0L, createdAt, 0L) + } + database.vitruvianDatabaseQueries.insertDefaultProfilePreferences(id, 1L) + } + + private inline fun jsonElement(value: T): JsonElement = testJson.encodeToJsonElement(value) + + private fun rackItem(id: String, name: String, weightKg: Float) = RackItem( + id = id, + name = name, + category = RackItemCategory.OTHER, + weightKg = weightKg, + behavior = RackItemBehavior.ADDED_RESISTANCE, + createdAt = 1L, + updatedAt = 1L, + ) + + private fun profileBackup(id: String, isActive: Boolean = false) = UserProfileBackup( + id = id, + name = id, + colorIndex = 0, + createdAt = if (id == PROFILE_A) 100L else 200L, + isActive = isActive, + ) + + private fun preferenceEntry( + profileId: String, + core: JsonElement? = null, + rack: JsonElement? = null, + workout: JsonElement? = null, + led: JsonElement? = null, + vbt: JsonElement? = null, + ) = buildJsonObject { + put("profileId", profileId) + core?.let { put("core", it) } + rack?.let { put("rack", it) } + workout?.let { put("workout", it) } + led?.let { put("led", it) } + vbt?.let { put("vbt", it) } + } + + private fun standardBackup( + version: Int, + identities: List, + preferences: List = emptyList(), + ): String = buildJsonObject { + put("version", version) + put("exportedAt", "2026-07-12T00:00:00Z") + put("appVersion", "test") + put("data", buildJsonObject { + put("userProfiles", testJson.encodeToJsonElement(identities)) + put("profilePreferences", JsonArray(preferences)) + }) + }.toString() + + private fun adversarialBackup( + finalVersion: Int, + identities: List, + preferences: List, + legacyRack: JsonElement, + ): String = buildJsonObject { + put("data", buildJsonObject { + put("profilePreferences", JsonArray(preferences)) + put("equipmentRackItems", legacyRack) + put("userProfiles", testJson.encodeToJsonElement(identities)) + put("futureData", buildJsonObject { put("ignored", true) }) + }) + put("futureRoot", JsonArray(listOf(JsonPrimitive("ignored")))) + put("version", finalVersion) + put("exportedAt", "2026-07-12T00:00:00Z") + put("appVersion", "test") + }.toString() + + private data class PreferenceFixture( + val driver: SqlDriver, + val database: VitruvianDatabase, + val preferences: ProfilePreferencesRepository, + val safetyStore: SettingsProfileLocalSafetyStore, + val recordingUserProfiles: RecordingUserProfileRepository, + val manager: TestDataBackupManager, + ) + + private class RecordingUserProfileRepository( + private val delegate: UserProfileRepository, + private val reconciliationFailure: Throwable? = null, + ) : UserProfileRepository by delegate { + var reconcileCalls: Int = 0 + private set + + override suspend fun reconcileActiveProfileContext() { + reconcileCalls++ + reconciliationFailure?.let { throw it } + delegate.reconcileActiveProfileContext() + } + } + + private class FaultingProfilePreferencesRepository( + private val delegate: ProfilePreferencesRepository, + private val failure: Throwable, + ) : ProfilePreferencesRepository by delegate { + override suspend fun updateCore(profileId: String, value: CoreProfilePreferences, now: Long) { + throw failure + } + } + + private companion object { + const val PROFILE_A = "profile-a" + const val PROFILE_B = "profile-b" + + fun createTestUserProfileRepository( + database: VitruvianDatabase, + preferences: ProfilePreferencesRepository, + ): UserProfileRepository = SqlDelightUserProfileRepository( + database = database, + profilePreferencesRepository = preferences, + profileLocalSafetyStore = SettingsProfileLocalSafetyStore(MapSettings()), + gamificationRepository = SqlDelightGamificationRepository(database), + ).also { + database.vitruvianDatabaseQueries.seedMissingProfilePreferences() + } + } + + @Test + fun `streaming import round-trip preserves all entities and profile preferences`() = runTest { // 1. Create original database and populate - val originalDb = createTestDatabase() + val originalFixture = preferenceFixture() + seedProfiles(originalFixture) + val originalDb = originalFixture.database val originalRepo = SqlDelightWorkoutRepository(originalDb, FakeExerciseRepository()) - val originalManager = TestDataBackupManager(originalDb) + val originalManager = originalFixture.manager // Insert sessions originalRepo.saveSession( @@ -591,10 +1275,20 @@ class StreamingImportRoundTripTest { assertEquals(1, originalBackup.data.routines.size, "Original should have 1 routine") assertEquals(1, originalBackup.data.routineExercises.size, "Original should have 1 routine exercise") assertTrue(originalBackup.data.personalRecords.isNotEmpty(), "Original should have personal records") + assertEquals(setOf("default", PROFILE_A, PROFILE_B), originalBackup.data.userProfiles.map { it.id }.toSet()) + val originalPreferenceEntries = testJson.parseToJsonElement(exportedJson).jsonObject + .getValue("data").jsonObject.getValue("profilePreferences").jsonArray + .associateBy { it.jsonObject.getValue("profileId").jsonPrimitive.content } + assertEquals(setOf("default", PROFILE_A, PROFILE_B), originalPreferenceEntries.keys) + val originalA = originalPreferenceEntries.getValue(PROFILE_A).jsonObject + listOf("core", "rack", "workout", "led", "vbt").forEach { section -> + assertTrue(section in originalA, "source export must include $section") + } // 3. Create FRESH database and import via streaming - val freshDb = createTestDatabase() - val freshManager = TestDataBackupManager(freshDb) + val freshFixture = preferenceFixture() + val freshDb = freshFixture.database + val freshManager = freshFixture.manager val source = StringBackupStreamSource(exportedJson) source.open() @@ -636,6 +1330,19 @@ class StreamingImportRoundTripTest { reExportedBackup.data.personalRecords.size, "Personal record count must match after round-trip", ) + assertEquals( + originalBackup.data.userProfiles.map { it.id }.toSet(), + reExportedBackup.data.userProfiles.map { it.id }.toSet(), + "Profile identities must survive streaming round-trip", + ) + val reExportedPreferenceEntries = testJson.parseToJsonElement(reExportedJson).jsonObject + .getValue("data").jsonObject.getValue("profilePreferences").jsonArray + .associateBy { it.jsonObject.getValue("profileId").jsonPrimitive.content } + assertEquals( + originalPreferenceEntries, + reExportedPreferenceEntries, + "All five preference sections must survive streaming round-trip", + ) // 6. Verify import counts match assertEquals(2, result.sessionsImported, "Should import 2 sessions") diff --git a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/util/DataBackupManagerRoutineNameTest.kt b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/util/DataBackupManagerRoutineNameTest.kt index b5da905dd..cf82e58c5 100644 --- a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/util/DataBackupManagerRoutineNameTest.kt +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/util/DataBackupManagerRoutineNameTest.kt @@ -1,27 +1,59 @@ package com.devil.phoenixproject.util -import com.devil.phoenixproject.data.repository.SettingsEquipmentRackRepository +import app.cash.sqldelight.db.QueryResult +import app.cash.sqldelight.db.SqlCursor +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.db.SqlPreparedStatement +import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver +import com.devil.phoenixproject.data.preferences.ProfileLocalSafetyStore +import com.devil.phoenixproject.data.preferences.SettingsProfileLocalSafetyStore +import com.devil.phoenixproject.data.repository.ProfilePreferencesRepository +import com.devil.phoenixproject.data.repository.SqlDelightGamificationRepository +import com.devil.phoenixproject.data.repository.SqlDelightProfilePreferencesRepository +import com.devil.phoenixproject.data.repository.SqlDelightUserProfileRepository import com.devil.phoenixproject.data.repository.SqlDelightWorkoutRepository +import com.devil.phoenixproject.data.repository.UserProfileRepository +import com.devil.phoenixproject.database.VitruvianDatabase +import com.devil.phoenixproject.domain.model.CoreProfilePreferences import com.devil.phoenixproject.domain.model.Exercise +import com.devil.phoenixproject.domain.model.LedPreferences +import com.devil.phoenixproject.domain.model.ProfileLocalSafetyPreferences import com.devil.phoenixproject.domain.model.RackItem import com.devil.phoenixproject.domain.model.RackItemBehavior import com.devil.phoenixproject.domain.model.RackItemCategory +import com.devil.phoenixproject.domain.model.RackPreferences import com.devil.phoenixproject.domain.model.Routine import com.devil.phoenixproject.domain.model.RoutineExercise import com.devil.phoenixproject.domain.model.ScalingBasis +import com.devil.phoenixproject.domain.model.VbtPreferences +import com.devil.phoenixproject.domain.model.WeightUnit +import com.devil.phoenixproject.domain.model.WorkoutPreferences import com.devil.phoenixproject.domain.model.WorkoutSession +import com.devil.phoenixproject.domain.model.UserProfilePreferences import com.devil.phoenixproject.testutil.FakeExerciseRepository import com.devil.phoenixproject.testutil.createTestDatabase import com.russhwolf.settings.MapSettings import java.io.File import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull +import kotlin.test.assertSame import kotlin.test.assertTrue import kotlinx.coroutines.test.runTest import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import org.junit.Before import org.junit.Test @@ -643,6 +675,8 @@ class DataBackupManagerRoutineNameTest { createdAt = 1_700_000_000_000, isActive = 1L, ) + queries.insertDefaultProfilePreferences("userA", 1L) + queries.setActiveProfile("userA") // 2. Insert a session and routine owned by "userA" queries.insertSession( @@ -772,6 +806,9 @@ class DataBackupManagerRoutineNameTest { // 1. Create two profiles; make "userA" active queries.insertProfile(id = "userA", name = "User A", colorIndex = 1L, createdAt = 1_700_000_000_000, isActive = 1L) queries.insertProfile(id = "userB", name = "User B", colorIndex = 2L, createdAt = 1_700_000_000_001, isActive = 0L) + queries.insertDefaultProfilePreferences("userA", 1L) + queries.insertDefaultProfilePreferences("userB", 1L) + queries.setActiveProfile("userA") // 2. Insert a session owned by "userB" queries.insertSession( @@ -1003,11 +1040,456 @@ class DataBackupManagerRoutineNameTest { } @Test - fun `v4 round-trip restores equipment rack items and routine rack defaults`() = runTest { - val sourceRackRepository = SettingsEquipmentRackRepository(MapSettings()) - val sourceManager = TestDataBackupManager(database, sourceRackRepository) - sourceRackRepository.saveItems( + fun `v5 buffered and streaming exports include valid profile sections and exclude legacy and local-only state`() = runTest { + val fixture = profileFixture() + seedDistinctProfilePreferences(fixture) + fixture.safetyStore.write( + PROFILE_A, + ProfileLocalSafetyPreferences( + safeWord = "distinctive-do-not-export-phrase", + safeWordCalibrated = true, + adultsOnlyConfirmed = true, + adultsOnlyPrompted = true, + ), + ) + executeSql( + fixture.driver, + "UPDATE UserProfilePreferences SET workout_preferences_json = ? WHERE profile_id = ?", + "{broken-workout-json", + PROFILE_B, + ) + + val buffered = fixture.manager.exportToJson() + val streaming = File(fixture.manager.exportToCachePublic()).readText() + + listOf(buffered, streaming).forEach { output -> + val root = testJson.parseToJsonElement(output).jsonObject + val data = root.getValue("data").jsonObject + assertEquals(5, root.getValue("version").jsonPrimitive.content.toInt()) + assertEquals(setOf(PROFILE_A, PROFILE_B, "default"), data.getValue("userProfiles").jsonArray + .map { it.jsonObject.getValue("id").jsonPrimitive.content } + .toSet()) + val preferenceEntries = data.getValue("profilePreferences").jsonArray + .associateBy { it.jsonObject.getValue("profileId").jsonPrimitive.content } + assertEquals(3, preferenceEntries.size) + assertEquals("70.0", preferenceEntries.getValue(PROFILE_A).jsonObject + .getValue("core").jsonObject.getValue("bodyWeightKg").jsonPrimitive.content) + assertTrue("rack" in preferenceEntries.getValue(PROFILE_A).jsonObject) + assertTrue("led" in preferenceEntries.getValue(PROFILE_A).jsonObject) + assertTrue("vbt" in preferenceEntries.getValue(PROFILE_A).jsonObject) + assertFalse("workout" in preferenceEntries.getValue(PROFILE_B).jsonObject) + assertFalse("equipmentRackItems" in data) listOf( + "localGeneration", + "serverRevision", + "dirty", + "distinctive-do-not-export-phrase", + "safeWord", + "safe_word", + "calibrated", + "adultsOnly", + "adult_confirm", + "adult_prompt", + ).forEach { forbidden -> assertFalse(output.contains(forbidden, ignoreCase = true), forbidden) } + } + } + + @Test + fun `v1 through v3 ignore supplied preferences and legacy rack`() = runTest { + for (version in 1..3) { + val fixture = profileFixture() + seedDistinctProfilePreferences(fixture) + val beforeA = fixture.preferences.get(PROFILE_A) + val payload = rawBackupJson( + version = version, + identities = listOf(profileBackup(PROFILE_A)), + profilePreferences = listOf( + preferenceEntry( + PROFILE_A, + core = jsonElement(CoreProfilePreferences(120f, WeightUnit.LB, 10f)), + rack = jsonElement(RackPreferences()), + ), + ), + legacyRackPresent = true, + legacyRack = JsonArray(emptyList()), + ) + + val result = fixture.manager.importFromJson(payload) + + assertTrue(result.isSuccess, "v$version: ${result.exceptionOrNull()}") + val afterA = fixture.preferences.get(PROFILE_A) + assertEquals(beforeA.core.value, afterA.core.value, "v$version core") + assertEquals(beforeA.rack.value, afterA.rack.value, "v$version rack") + } + } + + @Test + fun `v4 legacy rack preserves all raw states and targets represented profiles exactly once`() = runTest { + suspend fun importRack( + present: Boolean, + raw: JsonElement, + identities: List = listOf(profileBackup(PROFILE_A), profileBackup(PROFILE_B)), + ): Pair { + val fixture = profileFixture() + seedDistinctProfilePreferences(fixture) + val payload = rawBackupJson( + version = 4, + identities = identities, + profilePreferences = listOf( + preferenceEntry(PROFILE_A, rack = jsonElement(RackPreferences())), + ), + legacyRackPresent = present, + legacyRack = raw, + ) + return fixture to fixture.manager.importFromJson(payload).getOrThrow() + } + + val missing = importRack(false, JsonNull).first + assertEquals(listOf("a-existing", "shared"), missing.preferences.get(PROFILE_A).rack.value.items.map { it.id }) + + val explicitNull = importRack(true, JsonNull) + assertEquals(1, explicitNull.second.entitiesWithErrors) + assertEquals(listOf("a-existing", "shared"), explicitNull.first.preferences.get(PROFILE_A).rack.value.items.map { it.id }) + + val scalar = importRack(true, JsonPrimitive("wrong-kind")) + assertEquals(1, scalar.second.entitiesWithErrors) + assertEquals(listOf("a-existing", "shared"), scalar.first.preferences.get(PROFILE_A).rack.value.items.map { it.id }) + + val malformedArray = importRack( + true, + JsonArray(listOf(buildJsonObject { put("id", "missing-required-fields") })), + ) + assertEquals(1, malformedArray.second.entitiesWithErrors) + assertEquals(listOf("a-existing", "shared"), malformedArray.first.preferences.get(PROFILE_A).rack.value.items.map { it.id }) + + val empty = importRack(true, JsonArray(emptyList())).first + assertTrue(empty.preferences.get(PROFILE_A).rack.value.items.isEmpty()) + assertTrue(empty.preferences.get(PROFILE_B).rack.value.items.isEmpty()) + + val replacement = rackItem("shared", "Imported shared", 99f) + val appended = rackItem("new-item", "New item", 3f) + val merged = importRack(true, jsonElement(listOf(replacement, appended))).first + assertEquals( + listOf("a-existing", "shared", "new-item"), + merged.preferences.get(PROFILE_A).rack.value.items.map { it.id }, + ) + assertEquals("Imported shared", merged.preferences.get(PROFILE_A).rack.value.items[1].name) + assertEquals( + listOf("b-existing", "shared", "new-item"), + merged.preferences.get(PROFILE_B).rack.value.items.map { it.id }, + ) + + val fallback = importRack(true, JsonArray(emptyList()), identities = emptyList()).first + assertTrue(fallback.preferences.get(PROFILE_A).rack.value.items.isEmpty()) + assertEquals( + listOf("b-existing", "shared"), + fallback.preferences.get(PROFILE_B).rack.value.items.map { it.id }, + "active fallback must not also mutate unrelated profiles", + ) + } + + @Test + fun `v5 restores valid sections independently through repository metadata semantics and allowlist`() = runTest { + val fixture = profileFixture() + seedDistinctProfilePreferences(fixture) + insertProfile(fixture, PROFILE_C, active = false) + fixture.preferences.updateCore(PROFILE_C, CoreProfilePreferences(55f, WeightUnit.KG, 1f), 2L) + executeSql( + fixture.driver, + "UPDATE UserProfilePreferences SET core_server_revision = 42, core_local_generation = 7, core_dirty = 0 WHERE profile_id = ?", + PROFILE_A, + ) + fixture.safetyStore.write(PROFILE_A, ProfileLocalSafetyPreferences("target-secret", true, true, true)) + val beforeA = fixture.preferences.get(PROFILE_A) + val beforeB = fixture.preferences.get(PROFILE_B) + val payload = rawBackupJson( + version = 5, + identities = listOf(profileBackup(PROFILE_A), profileBackup(PROFILE_B)), + profilePreferences = listOf( + preferenceEntry( + PROFILE_A, + core = JsonPrimitive("wrong-kind"), + workout = jsonElement(WorkoutPreferences(stopAtTop = true, summaryCountdownSeconds = 30)), + led = jsonElement(LedPreferences(colorScheme = 11, discoModeUnlocked = true)), + ), + preferenceEntry( + PROFILE_B, + core = jsonElement(CoreProfilePreferences(101f, WeightUnit.LB, 5f)), + vbt = jsonElement(VbtPreferences(enabled = false, velocityLossThresholdPercent = 45)), + ), + preferenceEntry( + PROFILE_C, + core = jsonElement(CoreProfilePreferences(130f, WeightUnit.KG, 2f)), + ), + preferenceEntry( + "missing-identity", + core = jsonElement(CoreProfilePreferences(140f, WeightUnit.KG, 2f)), + ), + ), + legacyRackPresent = true, + legacyRack = JsonArray(emptyList()), + ) + + val result = fixture.manager.importFromJson(payload) + + assertTrue(result.isSuccess, result.exceptionOrNull()?.toString()) + assertEquals(1, result.getOrThrow().entitiesWithErrors) + val afterA = fixture.preferences.get(PROFILE_A) + val afterB = fixture.preferences.get(PROFILE_B) + assertEquals(beforeA.core.value, afterA.core.value, "invalid core is non-destructive") + assertEquals(42L, afterA.core.metadata.serverRevision) + assertEquals(7L, afterA.core.metadata.localGeneration) + assertFalse(afterA.core.metadata.dirty) + assertEquals(30, afterA.workout.value.summaryCountdownSeconds) + assertEquals(11, afterA.led.value.colorScheme) + assertEquals(101f, afterB.core.value.bodyWeightKg) + assertEquals(beforeB.core.metadata.serverRevision, afterB.core.metadata.serverRevision) + assertEquals(beforeB.core.metadata.localGeneration + 1, afterB.core.metadata.localGeneration) + assertTrue(afterB.core.metadata.dirty) + assertEquals(55f, fixture.preferences.get(PROFILE_C).core.value.bodyWeightKg) + assertEquals("target-secret", fixture.safetyStore.read(PROFILE_A).safeWord) + assertEquals(1, fixture.userProfiles.reconcileCalls) + assertEquals(70f, fixture.userProfiles.observedPreferences?.core?.value?.bodyWeightKg) + } + + @Test + fun `v5 ignores legacy rack and v6 restores known fields while dropping unknown fields`() = runTest { + val fixture = profileFixture() + seedDistinctProfilePreferences(fixture) + val entry = buildJsonObject { + put("profileId", PROFILE_A) + put("core", buildJsonObject { + put("bodyWeightKg", 88f) + put("weightUnit", "KG") + put("weightIncrement", 2.5f) + put("unknownCoreField", true) + }) + put("unknownEntryField", buildJsonObject { put("nested", true) }) + } + val v6 = rawBackupJson( + version = 6, + identities = listOf(profileBackup(PROFILE_A)), + profilePreferences = listOf(entry), + legacyRackPresent = true, + legacyRack = JsonArray(emptyList()), + unknownData = true, + unknownRoot = true, + ) + + val result = fixture.manager.importFromJson(v6) + + assertTrue(result.isSuccess, result.exceptionOrNull()?.toString()) + assertEquals(88f, fixture.preferences.get(PROFILE_A).core.value.bodyWeightKg) + assertEquals( + listOf("a-existing", "shared"), + fixture.preferences.get(PROFILE_A).rack.value.items.map { it.id }, + "v5+ must ignore a supplied legacy rack", + ) + } + + @Test + fun `backup active flags are informational and reconciliation failures do not replace restore failures`() = runTest { + listOf( + listOf(false, false), + listOf(false, true), + listOf(true, true), + ).forEach { flags -> + val fixture = profileFixture() + seedDistinctProfilePreferences(fixture) + val payload = rawBackupJson( + version = 5, + identities = listOf( + profileBackup(PROFILE_A, flags[0]), + profileBackup(PROFILE_B, flags[1]), + ), + ) + assertTrue(fixture.manager.importFromJson(payload).isSuccess) + val profiles = fixture.database.vitruvianDatabaseQueries.getAllProfiles().executeAsList() + assertEquals(PROFILE_A, profiles.single { it.isActive == 1L }.id) + } + + val restoreFailure = IllegalStateException("preference storage failed") + val reconcileFailure = IllegalArgumentException("reconcile failed") + val fixture = profileFixture( + preferenceDecorator = { delegate -> + FaultingProfilePreferencesRepository(delegate, restoreFailure) + }, + reconcileFailure = reconcileFailure, + ) + val payload = rawBackupJson( + version = 5, + identities = listOf(profileBackup("default")), + profilePreferences = listOf( + preferenceEntry( + "default", + core = jsonElement(CoreProfilePreferences(80f, WeightUnit.KG, 2.5f)), + ), + ), + ) + + val result = fixture.manager.importFromJson(payload) + + assertTrue(result.isFailure) + assertSame(restoreFailure, result.exceptionOrNull()) + assertTrue(result.exceptionOrNull()!!.suppressed.any { it.message == reconcileFailure.message }) + assertEquals(1, fixture.userProfiles.reconcileCalls) + } + + @Test + fun `identity query failure is fatal for buffered and streaming export and deletes partial output`() = runTest { + val driver = FailingIdentityQueryDriver(JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)) + val fixture = profileFixture(driver) + seedDistinctProfilePreferences(fixture) + driver.failIdentityQueries = true + + val bufferedFailure = runCatching { fixture.manager.exportAllData() }.exceptionOrNull() + assertNotNull(bufferedFailure, "buffered export must not substitute an empty identity list") + + val streamingFailure = runCatching { fixture.manager.exportToCachePublic() }.exceptionOrNull() + assertNotNull(streamingFailure, "streaming export must not substitute an empty identity list") + val partialPath = assertNotNull(fixture.manager.lastWriterPath) + assertFalse(File(partialPath).exists(), "failed streaming export must delete its partial file") + } + + @Test + fun `missing profile preference aggregate is fatal for both exports and deletes streaming partial output`() = runTest { + val fixture = profileFixture() + seedDistinctProfilePreferences(fixture) + executeSql( + fixture.driver, + "DELETE FROM UserProfilePreferences WHERE profile_id = ?", + PROFILE_B, + ) + + val bufferedFailure = runCatching { fixture.manager.exportAllData() }.exceptionOrNull() + assertNotNull(bufferedFailure, "buffered export must fail when an identity aggregate is missing") + + val streamingFailure = runCatching { fixture.manager.exportToCachePublic() }.exceptionOrNull() + assertNotNull(streamingFailure, "streaming export must fail when an identity aggregate is missing") + val partialPath = assertNotNull(fixture.manager.lastWriterPath) + assertFalse(File(partialPath).exists(), "failed streaming export must delete its partial file") + } + + @Test + fun `buffered legacy and absent explicit owners use represented fallback when active is absent`() = runTest { + val fixture = profileFixture() + val represented = "first-represented" + fun session(id: String, profileId: String?) = WorkoutSessionBackup( + id = id, + timestamp = 1L, + mode = "Old School", + targetReps = 1, + weightPerCableKg = 1f, + progressionKg = 0f, + duration = 1L, + totalReps = 1, + warmupReps = 0, + workingReps = 1, + isJustLift = false, + stopAtTop = false, + profileId = profileId, + ) + val explicitDefault = session("explicit-default", "original-owner") + val explicitRepresented = session("explicit-represented", "original-owner") + val seedResult = fixture.manager.importFromJson( + testJson.encodeToString( + BackupData( + version = 5, + exportedAt = "seed", + appVersion = "test", + data = BackupContent( + workoutSessions = listOf(explicitDefault, explicitRepresented), + ), + ), + ), + ) + assertTrue(seedResult.isSuccess, seedResult.exceptionOrNull()?.toString()) + executeSql(fixture.driver, "DELETE FROM UserProfile WHERE id = ?", "default") + val payload = BackupData( + version = 5, + exportedAt = "2026-07-12T00:00:00Z", + appVersion = "test", + data = BackupContent( + userProfiles = listOf(profileBackup(represented)), + workoutSessions = listOf( + session("legacy-fallback-session", null), + explicitDefault.copy(profileId = "default"), + explicitRepresented.copy(profileId = represented), + ), + ), + ) + + val result = fixture.manager.importFromJson(testJson.encodeToString(payload)) + + assertTrue(result.isSuccess, result.exceptionOrNull()?.toString()) + assertEquals( + represented, + fixture.database.vitruvianDatabaseQueries + .selectSessionById("legacy-fallback-session") + .executeAsOne() + .profile_id, + ) + assertEquals( + represented, + fixture.database.vitruvianDatabaseQueries + .selectSessionById(explicitDefault.id) + .executeAsOne() + .profile_id, + ) + assertEquals( + represented, + fixture.database.vitruvianDatabaseQueries + .selectSessionById(explicitRepresented.id) + .executeAsOne() + .profile_id, + ) + assertEquals( + represented, + fixture.database.vitruvianDatabaseQueries.getActiveProfile().executeAsOne().id, + ) + } + + @Test + fun `buffered session import adopts explicit owner when its profile is absent`() = runTest { + val activeProfileId = database.vitruvianDatabaseQueries.getActiveProfile().executeAsOne().id + val session = WorkoutSessionBackup( + id = "buffered-missing-profile-session", + timestamp = 1L, + mode = "Old School", + targetReps = 1, + weightPerCableKg = 1f, + progressionKg = 0f, + duration = 1L, + totalReps = 1, + warmupReps = 0, + workingReps = 1, + isJustLift = false, + stopAtTop = false, + profileId = "deleted-source-profile", + ) + val payload = BackupData( + version = 5, + exportedAt = "2026-07-15T00:00:00Z", + appVersion = "test", + data = BackupContent(workoutSessions = listOf(session)), + ) + + val result = backupManager.importFromJson(testJson.encodeToString(payload)) + + assertTrue(result.isSuccess, result.exceptionOrNull()?.toString()) + assertEquals( + activeProfileId, + database.vitruvianDatabaseQueries.selectSessionById(session.id).executeAsOne().profile_id, + ) + } + + @Test + fun `v5 round-trip restores profile rack and routine rack defaults`() = runTest { + val source = profileFixture() + seedDistinctProfilePreferences(source) + source.preferences.updateRack( + PROFILE_A, + RackPreferences(items = listOf( RackItem( id = "vest", name = "Weighted Vest", @@ -1015,9 +1497,11 @@ class DataBackupManagerRoutineNameTest { weightKg = 10f, behavior = RackItemBehavior.ADDED_RESISTANCE, ), - ), + )), + 30L, ) - workoutRepository.saveRoutine( + val sourceWorkoutRepository = SqlDelightWorkoutRepository(source.database, FakeExerciseRepository()) + sourceWorkoutRepository.saveRoutine( buildRoutine( routineId = "routine-rack-backup", routineName = "Rack Backup", @@ -1032,16 +1516,14 @@ class DataBackupManagerRoutineNameTest { }, ) - val backupJson = sourceManager.exportToJson() - val targetDatabase = createTestDatabase() - val targetRackRepository = SettingsEquipmentRackRepository(MapSettings()) - val targetManager = TestDataBackupManager(targetDatabase, targetRackRepository) + val backupJson = source.manager.exportToJson() + val target = profileFixture() - val importResult = targetManager.importFromJson(backupJson) + val importResult = target.manager.importFromJson(backupJson) - assertTrue(importResult.isSuccess, "v4 backup import must succeed: ${importResult.exceptionOrNull()?.message}") - assertEquals("vest", targetRackRepository.getItems().single().id) - val importedExercise = targetDatabase.vitruvianDatabaseQueries + assertTrue(importResult.isSuccess, "v5 backup import must succeed: ${importResult.exceptionOrNull()?.message}") + assertEquals("vest", target.preferences.get(PROFILE_A).rack.value.items.single().id) + val importedExercise = target.database.vitruvianDatabaseQueries .selectAllRoutineExercisesSync() .executeAsList() .single() @@ -1112,19 +1594,289 @@ class DataBackupManagerRoutineNameTest { ) } + private fun profileFixture( + driver: SqlDriver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY), + preferenceDecorator: (ProfilePreferencesRepository) -> ProfilePreferencesRepository = { it }, + reconcileFailure: Throwable? = null, + ): PreferenceFixture { + VitruvianDatabase.Schema.create(driver) + val fixtureDatabase = VitruvianDatabase(driver) + val realPreferences = SqlDelightProfilePreferencesRepository(fixtureDatabase) + val effectivePreferences = preferenceDecorator(realPreferences) + val safetyStore = SettingsProfileLocalSafetyStore(MapSettings()) + val realUserProfiles = SqlDelightUserProfileRepository( + database = fixtureDatabase, + profilePreferencesRepository = effectivePreferences, + profileLocalSafetyStore = safetyStore, + gamificationRepository = SqlDelightGamificationRepository(fixtureDatabase), + ) + fixtureDatabase.vitruvianDatabaseQueries.seedMissingProfilePreferences() + val recordingUserProfiles = RecordingUserProfileRepository( + delegate = realUserProfiles, + preferences = effectivePreferences, + reconciliationFailure = reconcileFailure, + ) + return PreferenceFixture( + driver = driver, + database = fixtureDatabase, + preferences = effectivePreferences, + safetyStore = safetyStore, + userProfiles = recordingUserProfiles, + manager = TestDataBackupManager( + database = fixtureDatabase, + profilePreferencesRepository = effectivePreferences, + userProfileRepository = recordingUserProfiles, + ), + ) + } + + private suspend fun seedDistinctProfilePreferences(fixture: PreferenceFixture) { + insertProfile(fixture, PROFILE_A, active = false) + insertProfile(fixture, PROFILE_B, active = false) + fixture.database.vitruvianDatabaseQueries.setActiveProfile(PROFILE_A) + fixture.preferences.updateCore(PROFILE_A, CoreProfilePreferences(70f, WeightUnit.KG, 2.5f), 10L) + fixture.preferences.updateRack( + PROFILE_A, + RackPreferences(items = listOf( + rackItem("a-existing", "A existing", 1f), + rackItem("shared", "A shared", 2f), + )), + 11L, + ) + fixture.preferences.updateWorkout( + PROFILE_A, + WorkoutPreferences(stopAtTop = true, summaryCountdownSeconds = 5), + 12L, + ) + fixture.preferences.updateLed(PROFILE_A, LedPreferences(colorScheme = 2), 13L) + fixture.preferences.updateVbt( + PROFILE_A, + VbtPreferences(enabled = false, velocityLossThresholdPercent = 30), + 14L, + ) + + fixture.preferences.updateCore(PROFILE_B, CoreProfilePreferences(90f, WeightUnit.LB, 5f), 20L) + fixture.preferences.updateRack( + PROFILE_B, + RackPreferences(items = listOf( + rackItem("b-existing", "B existing", 4f), + rackItem("shared", "B shared", 5f), + )), + 21L, + ) + fixture.preferences.updateWorkout( + PROFILE_B, + WorkoutPreferences(beepsEnabled = false, summaryCountdownSeconds = 15), + 22L, + ) + fixture.preferences.updateLed(PROFILE_B, LedPreferences(colorScheme = 7), 23L) + fixture.preferences.updateVbt( + PROFILE_B, + VbtPreferences(enabled = true, velocityLossThresholdPercent = 40), + 24L, + ) + } + + private fun insertProfile(fixture: PreferenceFixture, id: String, active: Boolean) { + if (fixture.database.vitruvianDatabaseQueries.getProfileById(id).executeAsOneOrNull() == null) { + fixture.database.vitruvianDatabaseQueries.insertProfile( + id = id, + name = id, + colorIndex = 0L, + createdAt = when (id) { + PROFILE_A -> 100L + PROFILE_B -> 200L + else -> 300L + }, + isActive = if (active) 1L else 0L, + ) + } + fixture.database.vitruvianDatabaseQueries.insertDefaultProfilePreferences(id, 1L) + if (active) fixture.database.vitruvianDatabaseQueries.setActiveProfile(id) + } + + private inline fun jsonElement(value: T): JsonElement = + testJson.encodeToJsonElement(value) + + private fun rackItem(id: String, name: String, weight: Float) = RackItem( + id = id, + name = name, + category = RackItemCategory.OTHER, + weightKg = weight, + behavior = RackItemBehavior.ADDED_RESISTANCE, + createdAt = 1L, + updatedAt = 1L, + ) + + private fun profileBackup(id: String, active: Boolean = false) = UserProfileBackup( + id = id, + name = id, + colorIndex = 0, + createdAt = when (id) { + PROFILE_A -> 100L + PROFILE_B -> 200L + else -> 300L + }, + isActive = active, + ) + + private fun preferenceEntry( + profileId: String, + core: JsonElement? = null, + rack: JsonElement? = null, + workout: JsonElement? = null, + led: JsonElement? = null, + vbt: JsonElement? = null, + ): JsonElement = buildJsonObject { + put("profileId", profileId) + core?.let { put("core", it) } + rack?.let { put("rack", it) } + workout?.let { put("workout", it) } + led?.let { put("led", it) } + vbt?.let { put("vbt", it) } + } + + private fun rawBackupJson( + version: Int, + identities: List = emptyList(), + profilePreferences: List = emptyList(), + legacyRackPresent: Boolean = false, + legacyRack: JsonElement = JsonNull, + unknownData: Boolean = false, + unknownRoot: Boolean = false, + ): String = buildJsonObject { + put("version", version) + put("exportedAt", "2026-07-12T00:00:00Z") + put("appVersion", "test") + put("data", buildJsonObject { + put("userProfiles", testJson.encodeToJsonElement(identities)) + put("profilePreferences", JsonArray(profilePreferences)) + if (legacyRackPresent) put("equipmentRackItems", legacyRack) + if (unknownData) put("futureData", buildJsonObject { put("ignored", true) }) + }) + if (unknownRoot) put("futureRoot", JsonArray(listOf(JsonPrimitive(1)))) + }.toString() + + private fun executeSql(driver: SqlDriver, sql: String, vararg values: Any?) { + driver.execute(null, sql, values.size) { + values.forEachIndexed { index, value -> + when (value) { + null -> bindString(index, null) + is String -> bindString(index, value) + is Long -> bindLong(index, value) + is Int -> bindLong(index, value.toLong()) + is Double -> bindDouble(index, value) + else -> error("Unsupported SQL value: $value") + } + } + } + } + + private data class PreferenceFixture( + val driver: SqlDriver, + val database: VitruvianDatabase, + val preferences: ProfilePreferencesRepository, + val safetyStore: ProfileLocalSafetyStore, + val userProfiles: RecordingUserProfileRepository, + val manager: TestDataBackupManager, + ) + + private class RecordingUserProfileRepository( + private val delegate: UserProfileRepository, + private val preferences: ProfilePreferencesRepository, + private val reconciliationFailure: Throwable? = null, + ) : UserProfileRepository by delegate { + var reconcileCalls: Int = 0 + private set + var observedPreferences: UserProfilePreferences? = null + private set + + override suspend fun reconcileActiveProfileContext() { + reconcileCalls++ + reconciliationFailure?.let { throw it } + delegate.reconcileActiveProfileContext() + delegate.activeProfile.value?.id?.let { activeId -> + observedPreferences = preferences.get(activeId) + } + } + } + + private class FaultingProfilePreferencesRepository( + private val delegate: ProfilePreferencesRepository, + private val failure: Throwable, + ) : ProfilePreferencesRepository by delegate { + override suspend fun updateCore(profileId: String, value: CoreProfilePreferences, now: Long) { + throw failure + } + } + + private class FailingIdentityQueryDriver( + private val delegate: SqlDriver, + ) : SqlDriver by delegate { + var failIdentityQueries: Boolean = false + + override fun executeQuery( + identifier: Int?, + sql: String, + mapper: (SqlCursor) -> QueryResult, + parameters: Int, + binders: (SqlPreparedStatement.() -> Unit)?, + ): QueryResult { + if (failIdentityQueries && + (identifier == SELECT_ALL_USER_PROFILES_IDENTIFIER || sql.contains("FROM UserProfile")) + ) { + throw IllegalStateException("injected identity export query failure") + } + return delegate.executeQuery(identifier, sql, mapper, parameters, binders) + } + + private companion object { + const val SELECT_ALL_USER_PROFILES_IDENTIFIER = -107_782_522 + } + } + + private companion object { + const val PROFILE_A = "profile-a" + const val PROFILE_B = "profile-b" + const val PROFILE_C = "profile-c" + + fun createTestUserProfileRepository( + database: VitruvianDatabase, + preferences: ProfilePreferencesRepository, + ): UserProfileRepository = SqlDelightUserProfileRepository( + database = database, + profilePreferencesRepository = preferences, + profileLocalSafetyStore = SettingsProfileLocalSafetyStore(MapSettings()), + gamificationRepository = SqlDelightGamificationRepository(database), + ).also { + database.vitruvianDatabaseQueries.seedMissingProfilePreferences() + } + } + private class TestDataBackupManager( database: com.devil.phoenixproject.database.VitruvianDatabase, - equipmentRackRepository: SettingsEquipmentRackRepository = SettingsEquipmentRackRepository(MapSettings()), + val profilePreferencesRepository: ProfilePreferencesRepository = SqlDelightProfilePreferencesRepository(database), + val userProfileRepository: UserProfileRepository = createTestUserProfileRepository( + database, + profilePreferencesRepository, + ), ) : BaseDataBackupManager( database, - equipmentRackRepository, + profilePreferencesRepository, + userProfileRepository, ) { + var lastWriterPath: String? = null + private set + override fun createBackupWriter(): BackupJsonWriter { val tempFile = File.createTempFile("backup-test-", ".json") + lastWriterPath = tempFile.absolutePath return BackupJsonWriter(tempFile.absolutePath) } + suspend fun exportToCachePublic(): String = exportToCache() + override suspend fun finalizeExport(tempFilePath: String): Result = Result.success(tempFilePath) override suspend fun saveToFile(backup: BackupData): Result { diff --git a/shared/src/androidMain/kotlin/com/devil/phoenixproject/AndroidAppHost.kt b/shared/src/androidMain/kotlin/com/devil/phoenixproject/AndroidAppHost.kt index 6ceca5373..581ed7488 100644 --- a/shared/src/androidMain/kotlin/com/devil/phoenixproject/AndroidAppHost.kt +++ b/shared/src/androidMain/kotlin/com/devil/phoenixproject/AndroidAppHost.kt @@ -1,6 +1,7 @@ package com.devil.phoenixproject import androidx.compose.runtime.Composable +import com.devil.phoenixproject.data.migration.MigrationManager import com.devil.phoenixproject.data.repository.ExerciseRepository import com.devil.phoenixproject.data.sync.SyncTriggerManager import com.devil.phoenixproject.presentation.viewmodel.EulaViewModel @@ -16,6 +17,7 @@ fun AndroidAppHost() { val eulaViewModel: EulaViewModel = koinInject() val exerciseRepository: ExerciseRepository = koinInject() val syncTriggerManager: SyncTriggerManager = koinInject() + val migrationManager: MigrationManager = koinInject() AppContent( mainViewModel = mainViewModel, @@ -23,5 +25,6 @@ fun AndroidAppHost() { eulaViewModel = eulaViewModel, exerciseRepository = exerciseRepository, syncTriggerManager = syncTriggerManager, + migrationManager = migrationManager, ) } diff --git a/shared/src/androidMain/kotlin/com/devil/phoenixproject/di/PlatformModule.android.kt b/shared/src/androidMain/kotlin/com/devil/phoenixproject/di/PlatformModule.android.kt index a91912e4d..854b430ae 100644 --- a/shared/src/androidMain/kotlin/com/devil/phoenixproject/di/PlatformModule.android.kt +++ b/shared/src/androidMain/kotlin/com/devil/phoenixproject/di/PlatformModule.android.kt @@ -65,7 +65,7 @@ actual val platformModule: Module = module { single { AndroidCsvExporter(androidContext()) } single { AndroidCsvImporter(androidContext(), get()) } single { AndroidBackupDestinationResolver(androidContext()) } - single { AndroidDataBackupManager(androidContext(), get(), get(), get(), get()) } + single { AndroidDataBackupManager(androidContext(), get(), get(), get(), get(), get()) } single { ConnectivityChecker(androidContext()) } single { AndroidSafeWordListenerFactory(androidContext()) } single { HealthIntegration(androidContext()) } diff --git a/shared/src/androidMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.android.kt b/shared/src/androidMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.android.kt index 59f79f4f4..a6fa44a6d 100644 --- a/shared/src/androidMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.android.kt +++ b/shared/src/androidMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.android.kt @@ -10,7 +10,8 @@ import androidx.core.content.FileProvider import androidx.core.net.toUri import co.touchlab.kermit.Logger import com.devil.phoenixproject.data.preferences.PreferencesManager -import com.devil.phoenixproject.data.repository.EquipmentRackRepository +import com.devil.phoenixproject.data.repository.ProfilePreferencesRepository +import com.devil.phoenixproject.data.repository.UserProfileRepository import com.devil.phoenixproject.database.VitruvianDatabase import java.io.File import kotlinx.coroutines.Dispatchers @@ -30,8 +31,9 @@ class AndroidDataBackupManager( database: VitruvianDatabase, private val preferencesManager: PreferencesManager, private val destinationResolver: BackupDestinationResolver, - equipmentRackRepository: EquipmentRackRepository, -) : BaseDataBackupManager(database, equipmentRackRepository) { + profilePreferencesRepository: ProfilePreferencesRepository, + userProfileRepository: UserProfileRepository, +) : BaseDataBackupManager(database, profilePreferencesRepository, userProfileRepository) { private val cacheDir: File get() { diff --git a/shared/src/commonMain/composeResources/values-de/strings.xml b/shared/src/commonMain/composeResources/values-de/strings.xml index 48ab26876..c726abaad 100644 --- a/shared/src/commonMain/composeResources/values-de/strings.xml +++ b/shared/src/commonMain/composeResources/values-de/strings.xml @@ -320,6 +320,7 @@ Tatsächlich verwendetes Gewicht (kg) Durchschnittsgeschwindigkeit (m/s) Bewertung abbrechen + Die Kraftmessung konnte nicht gespeichert werden. Versuche es erneut. Eigene Schätzung eingeben (kg) Leer lassen, um den berechneten Wert zu verwenden Bitte gib eine gültige Zahl ein @@ -347,6 +348,7 @@ Analyse Trainings + Einblicke Einstellungen Trainings @@ -516,7 +518,7 @@ App information Support the developer LED biofeedback - LED color scheme + LED-Farbschema LEDs Off Test sounds @@ -588,7 +590,7 @@ Below PR Delete workout Design wechseln (aktuell: %1$s, nächstes: %2$s) - Workouts + Startseite Trennen @@ -625,7 +627,7 @@ Dominatrix-Modus Charlotte-Sprachbefehle. Überschreibt das vulgäre Pool, wenn aktiv. Tippe 7× auf den Header zum Entsperren - Boss-Modus freigeschaltet + Dominatrix-Modus freigeschaltet Nur für Erwachsene Der vulgäre Modus enthält Kraftausdrücke und reife Themen. Bist du 18 oder älter? Ich bin 18+ und Oma hört mich nicht @@ -705,4 +707,91 @@ Warm-up Set %1$d Working Set + Profil + Profil + Profilwechsler öffnen + Profile + Profil wechseln + Übungsanalysen + Übung auswählen + Keine Übungshistorie für dieses Profil + Aktuelles 1RM + Geschwindigkeitsschätzung + Krafttest + Letzte abgeschlossene Einheit + Keine profilspezifische Schätzung + PR-Höhepunkte + Maximalgewicht + Geschätztes 1RM + Maximalvolumen + Letzter Verlauf + Gesamten Verlauf anzeigen + Einstellungen + Messwerte + Trainingsverhalten + LED + Geschwindigkeitsbasiertes Training + Sicherheit + VBT im Live-Training verwenden + Profil wird gewechselt… + Diese Übung ist nicht mehr verfügbar + Übungsanalysen konnten nicht geladen werden + Diese Profileinstellung konnte nicht gespeichert werden + Profilwechsel fehlgeschlagen + Profil konnte nicht erstellt werden + Profilwiederherstellung erforderlich + Phoenix konnte nicht bestätigen, welches Profil aktiv ist. Versuche es erneut, bevor du fortfährst. + Profilwiederherstellung ist weiterhin nicht verfügbar + „%1$s“ löschen? Trainings, Routinen, Rekorde, Abzeichen, Tests und Fortschrittsdaten werden zu Default verschoben. Dies kann nicht rückgängig gemacht werden. + Videoverhalten + Übungsvideos anzeigen + Übungsdemonstrationen anzeigen; auf langsameren Geräten deaktivieren + Gewichtsschritt + Körpergewicht + Dauer der Satzzusammenfassung + Autostart-Countdown + Routine automatisch starten + Gesprochener Wiederholungszähler + Countdown-Töne + Wiederholungston + Satzstart durch Bewegung + Gamification + Standardbasis für Gewichtsskalierung + Oben stoppen + Stillstandserkennung + Schwelle für Geschwindigkeitsverlust + Bei Geschwindigkeitsverlust automatisch beenden + Das Ausschalten von VBT betrifft nur Live-Trainings; gespeicherte Schätzungen und Tests bleiben verfügbar. + Blau + Grün + Bernstein + Rot + Lila + Rosa + Cyan + Orange + Profilfarbe %1$s auswählen + Automatisch + Standardpause + Trainingstöne + Zeitpunkt der Wiederholungszählung + Nicht festgelegt + Gib ein Körpergewicht zwischen 20 und 300 kg ein + Entspricht einer importierten Gesundheitsmessung + Blau + Grün + Türkis + Gelb + Rosa + Rot + Lila + Keine + LED-Schema auswählen: %1$s + Ausgewählt: %1$s + Disco-Modus + Verbinde deinen Trainer, um den Disco-Modus zu verwenden + Disco-Modus freigeschaltet + Aktiviere den Disco-Modus in den LED-Einstellungen, damit dein Trainer Party macht. + Party starten + Die Altersbestätigung wurde gespeichert, aber der vulgäre Modus konnte nicht aktiviert werden. Versuche es erneut. diff --git a/shared/src/commonMain/composeResources/values-es/strings.xml b/shared/src/commonMain/composeResources/values-es/strings.xml index 32067b2a3..86f751aed 100644 --- a/shared/src/commonMain/composeResources/values-es/strings.xml +++ b/shared/src/commonMain/composeResources/values-es/strings.xml @@ -320,6 +320,7 @@ Peso realmente usado (kg) Velocidad media (m/s) Cancelar evaluación + No se pudo guardar la evaluación. Inténtalo de nuevo. Ingresa tu propia estimación (kg) Deja vacío para usar el valor calculado Por favor ingresa un número válido @@ -347,6 +348,7 @@ Análisis Entrenamientos + Perspectivas Configuración Entrenamientos @@ -516,7 +518,7 @@ App information Support the developer LED biofeedback - LED color scheme + Esquema de color LED LEDs Off Test sounds @@ -588,7 +590,7 @@ Below PR Delete workout Cambiar tema (actual: %1$s, siguiente: %2$s) - Workouts + Inicio Desconectar @@ -625,7 +627,7 @@ Modo Dominatrix Comandos de voz de Charlotte. Anula el grupo vulgar cuando está activo. Toca el encabezado 7 veces para desbloquear - Modo jefe desbloqueado + Modo Dominatrix desbloqueado Solo para adultos El modo vulgar contiene groserías y temas maduros. ¿Tienes 18 años o más? Tengo 18+ y la abuela no me oye @@ -705,4 +707,91 @@ Warm-up Set %1$d Working Set + Perfil + Perfil + Abrir selector de perfiles + Perfiles + Cambiar perfil + Análisis del ejercicio + Elegir un ejercicio + No hay historial de ejercicios para este perfil + 1RM actual + Estimación por velocidad + Evaluación de fuerza + Sesión completada reciente + No hay estimación para este perfil + Récords destacados + Peso máximo + 1RM estimado + Volumen máximo + Historial reciente + Ver historial completo + Preferencias + Mediciones + Comportamiento del entrenamiento + LED + Entrenamiento basado en velocidad + Seguridad + Usar VBT durante entrenamientos en vivo + Cambiando perfil… + Este ejercicio ya no está disponible + No se pudo cargar el análisis del ejercicio + No se pudo guardar esta preferencia del perfil + No se pudo cambiar de perfil + No se pudo crear el perfil + Se requiere recuperar el perfil + Phoenix no pudo confirmar qué perfil está activo. Vuelve a intentarlo antes de continuar. + La recuperación del perfil sigue sin estar disponible + ¿Eliminar "%1$s"? Sus entrenamientos, rutinas, récords, insignias, evaluaciones y datos de progreso pasarán a Default. Esta acción no se puede deshacer. + Comportamiento del vídeo + Mostrar vídeos de ejercicios + Mostrar demostraciones; desactívalo en dispositivos más lentos + Incremento de peso + Peso corporal + Duración del resumen de serie + Cuenta atrás de inicio automático + Iniciar rutina automáticamente + Contador de repeticiones por voz + Pitidos de cuenta atrás + Sonido al completar repetición + Iniciar serie con movimiento + Gamificación + Base predeterminada de escala de peso + Detener arriba + Detección de bloqueo + Umbral de pérdida de velocidad + Finalizar al perder velocidad + Desactivar VBT solo afecta a los entrenamientos en vivo; las estimaciones y evaluaciones guardadas siguen disponibles. + Azul + Verde + Ámbar + Rojo + Morado + Rosa + Cian + Naranja + Seleccionar %1$s como color del perfil + Automático + Descanso predeterminado + Pitidos del entrenamiento + Momento del recuento de repeticiones + Sin configurar + Introduce un peso corporal de 20 a 300 kg + Coincide con una medición de salud importada + Azul + Verde + Verde azulado + Amarillo + Rosa + Rojo + Morado + Ninguno + Seleccionar esquema LED: %1$s + Seleccionado: %1$s + Modo disco + Conecta tu entrenador para usar el modo disco + Modo disco desbloqueado + Activa el modo disco en las preferencias LED para que tu entrenador se una a la fiesta. + ¡A bailar! + La confirmación de edad se guardó, pero no se pudo activar el modo vulgar. Inténtalo de nuevo. diff --git a/shared/src/commonMain/composeResources/values-fr/strings.xml b/shared/src/commonMain/composeResources/values-fr/strings.xml index 94a421157..a3aa2647b 100644 --- a/shared/src/commonMain/composeResources/values-fr/strings.xml +++ b/shared/src/commonMain/composeResources/values-fr/strings.xml @@ -320,6 +320,7 @@ Poids réellement utilisé (kg) Vitesse moyenne (m/s) Annuler l\'évaluation + Impossible d’enregistrer l’évaluation. Réessayez. Entrez votre propre estimation (kg) Laissez vide pour utiliser la valeur calculée Veuillez entrer un nombre valide @@ -347,6 +348,7 @@ Analyse Entraînements + Analyses Paramètres Entraînements @@ -516,7 +518,7 @@ App information Support the developer LED biofeedback - LED color scheme + Palette de couleurs LED LEDs Off Test sounds @@ -588,7 +590,7 @@ Below PR Delete workout Changer de thème (actuel : %1$s, suivant : %2$s) - Workouts + Accueil D\u00e9connecter @@ -625,7 +627,7 @@ Mode Dominatrix Commandes vocales de Charlotte. Remplace le pool vulgaire lorsqu\'il est actif. Tape 7× sur l\'en-tête pour déverrouiller - Mode patron déverrouillé + Mode Dominatrix déverrouillé Réservé aux adultes Le mode vulgaire contient des grossièretés et des thèmes adultes. Avez-vous 18 ans ou plus ? J\'ai 18+ et mamie ne m\'entend pas @@ -705,4 +707,91 @@ Warm-up Set %1$d Working Set + Profil + Profil + Ouvrir le sélecteur de profil + Profils + Changer de profil + Analyse de l'exercice + Choisir un exercice + Aucun historique d'exercice pour ce profil + 1RM actuel + Estimation par vélocité + Évaluation de force + Séance terminée récente + Aucune estimation propre à ce profil + Records marquants + Poids maximal + 1RM estimé + Volume maximal + Historique récent + Voir tout l'historique + Préférences + Mesures + Comportement d'entraînement + LED + Entraînement basé sur la vélocité + Sécurité + Utiliser le VBT pendant les entraînements en direct + Changement de profil… + Cet exercice n'est plus disponible + Impossible de charger l'analyse de l'exercice + Impossible d'enregistrer cette préférence du profil + Impossible de changer de profil + Impossible de créer le profil + Récupération du profil requise + Phoenix n’a pas pu confirmer quel profil est actif. Réessayez avant de continuer. + La récupération du profil est toujours indisponible + Supprimer « %1$s » ? Ses entraînements, routines, records, badges, évaluations et données de progression seront transférés vers Default. Cette action est irréversible. + Comportement vidéo + Afficher les vidéos d'exercice + Afficher les démonstrations ; désactivez-les sur les appareils plus lents + Incrément de poids + Poids corporel + Durée du résumé de série + Compte à rebours automatique + Démarrer la routine automatiquement + Compteur vocal de répétitions + Bips du compte à rebours + Son de fin de répétition + Démarrer la série par mouvement + Ludification + Base de mise à l'échelle du poids + Arrêt en haut + Détection de blocage + Seuil de perte de vélocité + Arrêt automatique sur perte de vélocité + Désactiver le VBT ne concerne que les entraînements en direct ; les estimations et évaluations enregistrées restent disponibles. + Bleu + Vert + Ambre + Rouge + Violet + Rose + Cyan + Orange + Sélectionner %1$s comme couleur du profil + Automatique + Repos par défaut + Bips d\'entraînement + Moment du comptage des répétitions + Non défini + Saisissez un poids corporel compris entre 20 et 300 kg + Correspond à une mesure de santé importée + Bleu + Vert + Turquoise + Jaune + Rose + Rouge + Violet + Aucun + Sélectionner le thème LED : %1$s + Sélectionné : %1$s + Mode disco + Connectez votre machine pour utiliser le mode disco + Mode disco déverrouillé + Activez le mode disco dans les préférences LED pour faire danser votre machine. + C\'est parti ! + La confirmation de l\'âge a été enregistrée, mais le mode vulgaire n\'a pas pu être activé. Réessayez. diff --git a/shared/src/commonMain/composeResources/values-it/strings.xml b/shared/src/commonMain/composeResources/values-it/strings.xml index ab7068fc5..a82f4c2a1 100644 --- a/shared/src/commonMain/composeResources/values-it/strings.xml +++ b/shared/src/commonMain/composeResources/values-it/strings.xml @@ -54,7 +54,7 @@ Modalità Dominatrix Comandi vocali di Charlotte. Sovrascrive il pool volgare quando attivo. Tocca l\'intestazione 7× per sbloccare - Modalità boss sbloccata + Modalità Dominatrix sbloccata Solo per adulti La modalità volgare contiene parolacce e temi per adulti. Hai 18 anni o più? Ho 18+ e la nonna non mi sente diff --git a/shared/src/commonMain/composeResources/values-nl/strings.xml b/shared/src/commonMain/composeResources/values-nl/strings.xml index 211bc6e7d..48f5a536b 100644 --- a/shared/src/commonMain/composeResources/values-nl/strings.xml +++ b/shared/src/commonMain/composeResources/values-nl/strings.xml @@ -321,6 +321,7 @@ Werkelijk gebruikt gewicht (kg) Gemiddelde snelheid (m/s) Beoordeling annuleren + De krachtmeting kon niet worden opgeslagen. Probeer het opnieuw. Voer je eigen schatting in (kg) Laat leeg om de berekende waarde te gebruiken Voer een geldig getal in @@ -348,6 +349,7 @@ Analyse Trainingen + Inzichten Instellingen Trainingen @@ -567,7 +569,7 @@ Onder PR Training verwijderen Thema wisselen (huidig: %1$s, volgend: %2$s) - Trainingen + Startpagina Verbinding verbreken @@ -604,7 +606,7 @@ Dominatrix-modus Charlotte-stemopdrachten. Overschrijft vulgaire pool indien actief. Tik 7× op de header om te ontgrendelen - Baasmodus ontgrendeld + Dominatrix-modus ontgrendeld Alleen voor volwassenen Vulgaire modus bevat krachttermen en volwassen thema\'s. Ben je 18 of ouder? Ik ben 18+ en oma hoort me niet @@ -684,4 +686,91 @@ Warm-up Set %1$d Working Set + Profiel + Profiel + Profielwisselaar openen + Profielen + Profiel wisselen + Oefeningsinzichten + Kies een oefening + Geen oefeningsgeschiedenis voor dit profiel + Huidige 1RM + Snelheidsschatting + Krachtmeting + Recente voltooide sessie + Geen profielgebonden schatting + PR-hoogtepunten + Maximaal gewicht + Geschatte 1RM + Maximaal volume + Recente geschiedenis + Volledige geschiedenis bekijken + Voorkeuren + Metingen + Trainingsgedrag + LED + Snelheidsgebaseerde training + Veiligheid + VBT gebruiken tijdens live trainingen + Profiel wisselen… + Deze oefening is niet meer beschikbaar + Oefeningsinzichten konden niet worden geladen + Deze profielvoorkeur kon niet worden opgeslagen + Profiel wisselen is mislukt + Profiel maken is mislukt + Profielherstel vereist + Phoenix kan niet bevestigen welk profiel actief is. Probeer opnieuw voordat u doorgaat. + Profielherstel is nog niet beschikbaar + "%1$s" verwijderen? Trainingen, routines, records, badges, metingen en voortgang worden naar Default verplaatst. Dit kan niet ongedaan worden gemaakt. + Videogedrag + Oefeningsvideo's tonen + Toon oefendemonstraties; schakel dit uit op tragere apparaten + Gewichtsstap + Lichaamsgewicht + Duur setoverzicht + Aftellen voor automatisch starten + Routine automatisch starten + Gesproken herhalingsteller + Aftelpiepjes + Geluid na herhaling + Set starten door beweging + Gamificatie + Standaardbasis voor gewichtschaal + Stoppen bovenaan + Stildetectie + Drempel voor snelheidsverlies + Automatisch stoppen bij snelheidsverlies + VBT uitschakelen beïnvloedt alleen live trainingen; opgeslagen schattingen en metingen blijven beschikbaar. + Blauw + Groen + Amber + Rood + Paars + Roze + Cyaan + Oranje + Selecteer %1$s als profielkleur + Automatisch + Standaardrusttijd + Trainingspiepjes + Moment van herhalingstelling + Niet ingesteld + Voer een lichaamsgewicht van 20 tot 300 kg in + Komt overeen met een geïmporteerde gezondheidsmeting + Blauw + Groen + Blauwgroen + Geel + Roze + Rood + Paars + Geen + Selecteer LED-schema: %1$s + Geselecteerd: %1$s + Discomodus + Verbind je trainer om de discomodus te gebruiken + Discomodus ontgrendeld + Schakel de discomodus in bij de LED-voorkeuren en laat je trainer feesten. + Tijd voor een feestje + De leeftijdsbevestiging is opgeslagen, maar de vulgaire modus kon niet worden ingeschakeld. Probeer het opnieuw. diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index d2ee0ed4d..976b24610 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -376,6 +376,7 @@ Actual Weight Used (kg) Mean Velocity (m/s) Cancel Assessment + Couldn’t save the assessment. Try again. Enter your own estimate (kg) Leave empty to use the calculated value Please enter a valid number @@ -412,6 +413,7 @@ Analytics Workouts + Insights Settings @@ -678,7 +680,7 @@ Below PR Delete workout Toggle theme (current: %1$s, next: %2$s) - Workouts + Home Disconnect Exercise is no longer available @@ -716,7 +718,7 @@ Dominatrix Mode Charlotte voice commands. Overrides vulgar pool when on. Tap header 7× to unlock - Boss Mode unlocked + Dominatrix Mode unlocked Adults Only Vulgar Mode contains profanity and mature themes. Are you 18 or older? I\'m 18+ and Grandma Can\'t Hear Me @@ -848,4 +850,82 @@ Connect to enable auto-start AUTO-START READY Grab handles to start + Profile + Profile + Open profile switcher + Profiles + Switch Profile + Exercise Insights + Choose an exercise + No exercise history for this profile + Current 1RM + Velocity estimate + Strength assessment + Recent completed session + No profile-scoped estimate + PR Highlights + Max weight + Estimated 1RM + Max volume + Recent History + View Full History + Preferences + Measurements + Workout Behavior + LED + Velocity-Based Training + Safety + Use VBT during live workouts + Switching profile… + This exercise is no longer available + Could not load exercise insights + Could not save this profile preference + Could not switch profiles + Could not create the profile + Profile recovery required + Phoenix could not confirm which profile is active. Retry before continuing. + Profile recovery is still unavailable + Delete "%1$s"? Its workouts, routines, records, badges, assessments, and progression data will move to Default. This cannot be undone. + Video Behavior + Show Exercise Videos + Display exercise demonstrations; turn this off on slower devices + Weight increment + Body weight + Set summary duration + Autostart countdown + Auto-start routine + Audio rep counter + Countdown beeps + Rep completion sound + Motion-triggered set start + Gamification + Default weight scaling basis + Stop at top + Stall detection + Velocity-loss threshold + Auto-end on velocity loss + Turning VBT off affects live workouts only; saved estimates and assessments remain available. + Automatic + Default rest + Workout beeps + Rep count timing + Not set + Enter a body weight from 20 to 300 kg + Matches an imported health measurement + Blue + Green + Teal + Yellow + Pink + Red + Purple + None + Select LED scheme: %1$s + Selected: %1$s + Disco Mode + Connect to your trainer to use Disco Mode + Disco Mode unlocked + Turn on Disco Mode in LED preferences to make your trainer party. + Let\'s party + Age confirmation was saved, but Vulgar Mode could not be enabled. Try again. diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/App.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/App.kt index 881a16a42..b75ec0384 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/App.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/App.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -23,6 +24,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -30,6 +32,8 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import co.touchlab.kermit.Logger +import com.devil.phoenixproject.data.migration.MigrationManager +import com.devil.phoenixproject.data.migration.RequiredMigrationState import com.devil.phoenixproject.data.repository.ExerciseRepository import com.devil.phoenixproject.data.sync.SyncTriggerManager import com.devil.phoenixproject.presentation.screen.EnhancedMainScreen @@ -44,14 +48,33 @@ import kotlin.time.Clock import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource +import vitruvianprojectphoenix.shared.generated.resources.Res +import vitruvianprojectphoenix.shared.generated.resources.action_retry private const val LAUNCH_SPLASH_DURATION_MS = 2_500L +internal enum class StartupSurface { EULA, SPLASH, MIGRATION_RETRY, MAIN } + +internal fun startupSurface( + eulaAccepted: Boolean, + splashCompleted: Boolean, + migrationState: RequiredMigrationState, +): StartupSurface = when { + !eulaAccepted -> StartupSurface.EULA + migrationState is RequiredMigrationState.Failed -> StartupSurface.MIGRATION_RETRY + migrationState != RequiredMigrationState.Ready || !splashCompleted -> StartupSurface.SPLASH + else -> StartupSurface.MAIN +} + /** * Observes app lifecycle and triggers sync on foreground. */ @Composable -private fun AppLifecycleObserver(syncTriggerManager: SyncTriggerManager) { +private fun AppLifecycleObserver( + syncTriggerManager: SyncTriggerManager, + migrationManager: MigrationManager, +) { val lifecycleOwner = LocalLifecycleOwner.current val scope = rememberCoroutineScope() @@ -60,6 +83,7 @@ private fun AppLifecycleObserver(syncTriggerManager: SyncTriggerManager) { if (event == Lifecycle.Event.ON_RESUME) { scope.launch { try { + migrationManager.awaitRequiredMigrations() syncTriggerManager.onAppForeground() } catch (e: CancellationException) { throw e @@ -111,6 +135,22 @@ fun CrashErrorScreen(error: String) { } } +@Composable +private fun MigrationRetryScreen(message: String, onRetry: () -> Unit) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column( + modifier = Modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(message, textAlign = TextAlign.Center) + Spacer(Modifier.height(16.dp)) + Button(onClick = onRetry) { + Text(stringResource(Res.string.action_retry)) + } + } + } +} + /** * Shared app content. Platform hosts own DI/lifecycle scoping and pass * retained dependencies into this pure UI entry point. @@ -122,17 +162,18 @@ fun AppContent( eulaViewModel: EulaViewModel, exerciseRepository: ExerciseRepository, syncTriggerManager: SyncTriggerManager, + migrationManager: MigrationManager, ) { val themeMode by themeViewModel.themeMode.collectAsState() val dynamicColorEnabled by themeViewModel.dynamicColorEnabled.collectAsState() val eulaAccepted by eulaViewModel.eulaAccepted.collectAsState() + val migrationState by migrationManager.requiredMigrationState.collectAsState() val dynamicColorAvailable = isDynamicColorAvailable() + val scope = rememberCoroutineScope() var launchSplashCompleted by rememberSaveable { mutableStateOf(false) } var launchSplashStartedAtMillis by rememberSaveable { mutableLongStateOf(0L) } - val showSplash = eulaAccepted && !launchSplashCompleted - LaunchedEffect(eulaAccepted, launchSplashCompleted, launchSplashStartedAtMillis) { if (!eulaAccepted) { launchSplashCompleted = false @@ -157,27 +198,33 @@ fun AppContent( launchSplashStartedAtMillis = 0L } - AppLifecycleObserver(syncTriggerManager) + LaunchedEffect(migrationManager) { + migrationManager.runRequiredMigrations() + } + + AppLifecycleObserver(syncTriggerManager, migrationManager) VitruvianTheme(themeMode = themeMode, dynamicColorEnabled = dynamicColorEnabled) { Box(modifier = Modifier.fillMaxSize()) { - if (!eulaAccepted) { - EulaScreen( - onAccept = { eulaViewModel.acceptEula() }, + when (startupSurface(eulaAccepted, launchSplashCompleted, migrationState)) { + StartupSurface.EULA -> EulaScreen(onAccept = eulaViewModel::acceptEula) + StartupSurface.SPLASH -> SplashScreen(visible = true) + StartupSurface.MIGRATION_RETRY -> MigrationRetryScreen( + message = (migrationState as RequiredMigrationState.Failed).message, + onRetry = { + scope.launch { migrationManager.retryRequiredMigrations() } + }, ) - } else if (!showSplash) { - EnhancedMainScreen( + StartupSurface.MAIN -> EnhancedMainScreen( viewModel = mainViewModel, exerciseRepository = exerciseRepository, themeMode = themeMode, - onThemeModeChange = { themeViewModel.setThemeMode(it) }, + onThemeModeChange = themeViewModel::setThemeMode, dynamicColorAvailable = dynamicColorAvailable, dynamicColorEnabled = dynamicColorEnabled, - onDynamicColorEnabledChange = { themeViewModel.setDynamicColorEnabled(it) }, + onDynamicColorEnabledChange = themeViewModel::setDynamicColorEnabled, ) } - - SplashScreen(visible = showSplash) } } } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/integration/HealthBodyWeightSyncManager.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/integration/HealthBodyWeightSyncManager.kt index 1476b434e..6933c6910 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/integration/HealthBodyWeightSyncManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/integration/HealthBodyWeightSyncManager.kt @@ -1,7 +1,11 @@ package com.devil.phoenixproject.data.integration import co.touchlab.kermit.Logger -import com.devil.phoenixproject.data.preferences.PreferencesManager +import com.devil.phoenixproject.data.migration.RequiredMigrationFailedException +import com.devil.phoenixproject.data.migration.RequiredMigrationGate +import com.devil.phoenixproject.data.repository.ActiveProfileContext +import com.devil.phoenixproject.data.repository.ProfileContextUnavailableException +import com.devil.phoenixproject.data.repository.StaleProfileContextException import com.devil.phoenixproject.data.repository.UserProfileRepository import com.devil.phoenixproject.domain.model.ConnectionStatus import com.devil.phoenixproject.domain.model.ExternalBodyMeasurement @@ -29,7 +33,7 @@ class HealthBodyWeightSyncManager( private val bodyWeightReader: HealthBodyWeightReader, private val externalActivityRepository: ExternalActivityRepository, private val externalMeasurementRepository: ExternalMeasurementRepository, - private val preferencesManager: PreferencesManager, + private val requiredMigrationGate: RequiredMigrationGate, private val userProfileRepository: UserProfileRepository, private val providerResolver: () -> IntegrationProvider = { if (isIosPlatform) IntegrationProvider.APPLE_HEALTH else IntegrationProvider.GOOGLE_HEALTH @@ -44,8 +48,17 @@ class HealthBodyWeightSyncManager( } suspend fun syncLatestFromConnectedPlatform(): HealthBodyWeightSyncResult { + val ready = try { + awaitReadyProfile() + } catch (error: RequiredMigrationFailedException) { + bodyWeightSyncLog.w(error) { "Body-weight sync blocked by required migration failure" } + return HealthBodyWeightSyncResult.Failed(error) + } catch (error: ProfileContextUnavailableException) { + bodyWeightSyncLog.w(error) { "Body-weight sync blocked while active profile was switching" } + return HealthBodyWeightSyncResult.Failed(error) + } val provider = providerResolver() - val profileId = userProfileRepository.activeProfile.value?.id ?: "default" + val profileId = ready.profile.id val status = externalActivityRepository.getIntegrationStatus(provider, profileId).first() if (status?.status != ConnectionStatus.CONNECTED) { @@ -114,8 +127,30 @@ class HealthBodyWeightSyncManager( profileId = profileId, syncedAt = now, ) + when (val current = userProfileRepository.activeProfileContext.value) { + is ActiveProfileContext.Ready -> if (current.profile.id != profileId) { + val error = StaleProfileContextException(profileId, current.profile.id) + bodyWeightSyncLog.w(error) { "Body-weight sync stopped after active profile changed" } + return HealthBodyWeightSyncResult.Failed(error) + } + is ActiveProfileContext.Switching -> { + val error = ProfileContextUnavailableException() + bodyWeightSyncLog.w(error) { "Body-weight sync stopped while active profile was switching" } + return HealthBodyWeightSyncResult.Failed(error) + } + } externalMeasurementRepository.upsertMeasurements(listOf(measurement)) - preferencesManager.setBodyWeightKg(sample.weightKg) + try { + userProfileRepository.mutateCore(profileId) { latest -> + latest.copy(bodyWeightKg = sample.weightKg) + } + } catch (error: StaleProfileContextException) { + bodyWeightSyncLog.w(error) { "Body-weight sync stopped after active profile changed" } + return HealthBodyWeightSyncResult.Failed(error) + } catch (error: ProfileContextUnavailableException) { + bodyWeightSyncLog.w(error) { "Body-weight sync stopped while active profile was switching" } + return HealthBodyWeightSyncResult.Failed(error) + } updateConnectedStatus( provider = provider, profileId = profileId, @@ -125,6 +160,12 @@ class HealthBodyWeightSyncManager( return HealthBodyWeightSyncResult.Synced(sample = sample, measurement = measurement) } + private suspend fun awaitReadyProfile(): ActiveProfileContext.Ready { + requiredMigrationGate.awaitRequiredMigrations() + return userProfileRepository.activeProfileContext.value as? ActiveProfileContext.Ready + ?: throw ProfileContextUnavailableException() + } + private suspend fun updateConnectedStatus( provider: IntegrationProvider, profileId: String, diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/local/MigrationStatements.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/local/MigrationStatements.kt index ab38558e1..51f71bd8f 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/local/MigrationStatements.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/local/MigrationStatements.kt @@ -964,5 +964,57 @@ WHERE gs.rowid = ( "ALTER TABLE TrainingCycle ADD COLUMN week_number INTEGER NOT NULL DEFAULT 1", ) + 42 -> listOf( + """CREATE TABLE UserProfilePreferences ( + profile_id TEXT PRIMARY KEY NOT NULL, + schema_version INTEGER NOT NULL DEFAULT 1, + legacy_migration_version INTEGER NOT NULL DEFAULT 0, + body_weight_kg REAL NOT NULL DEFAULT 0 CHECK(body_weight_kg = 0 OR body_weight_kg BETWEEN 20 AND 300), + weight_unit TEXT NOT NULL DEFAULT 'LB' CHECK(weight_unit IN ('KG', 'LB')), + weight_increment REAL NOT NULL DEFAULT -1 CHECK(weight_increment = -1 OR weight_increment > 0), + core_updated_at INTEGER NOT NULL DEFAULT 0, + core_local_generation INTEGER NOT NULL DEFAULT 0 CHECK(core_local_generation >= 0), + core_server_revision INTEGER NOT NULL DEFAULT 0 CHECK(core_server_revision >= 0), + core_dirty INTEGER NOT NULL DEFAULT 1 CHECK(core_dirty IN (0, 1)), + equipment_rack_json TEXT NOT NULL DEFAULT '{"version":1,"items":[]}', + rack_updated_at INTEGER NOT NULL DEFAULT 0, + rack_local_generation INTEGER NOT NULL DEFAULT 0 CHECK(rack_local_generation >= 0), + rack_server_revision INTEGER NOT NULL DEFAULT 0 CHECK(rack_server_revision >= 0), + rack_dirty INTEGER NOT NULL DEFAULT 1 CHECK(rack_dirty IN (0, 1)), + workout_preferences_json TEXT NOT NULL DEFAULT '{"version":1}', + workout_updated_at INTEGER NOT NULL DEFAULT 0, + workout_local_generation INTEGER NOT NULL DEFAULT 0 CHECK(workout_local_generation >= 0), + workout_server_revision INTEGER NOT NULL DEFAULT 0 CHECK(workout_server_revision >= 0), + workout_dirty INTEGER NOT NULL DEFAULT 1 CHECK(workout_dirty IN (0, 1)), + led_color_scheme_id INTEGER NOT NULL DEFAULT 0 CHECK(led_color_scheme_id >= 0), + led_preferences_json TEXT NOT NULL DEFAULT '{"version":1}', + led_updated_at INTEGER NOT NULL DEFAULT 0, + led_local_generation INTEGER NOT NULL DEFAULT 0 CHECK(led_local_generation >= 0), + led_server_revision INTEGER NOT NULL DEFAULT 0 CHECK(led_server_revision >= 0), + led_dirty INTEGER NOT NULL DEFAULT 1 CHECK(led_dirty IN (0, 1)), + vbt_enabled INTEGER NOT NULL DEFAULT 1 CHECK(vbt_enabled IN (0, 1)), + vbt_preferences_json TEXT NOT NULL DEFAULT '{"version":1}', + vbt_updated_at INTEGER NOT NULL DEFAULT 0, + vbt_local_generation INTEGER NOT NULL DEFAULT 0 CHECK(vbt_local_generation >= 0), + vbt_server_revision INTEGER NOT NULL DEFAULT 0 CHECK(vbt_server_revision >= 0), + vbt_dirty INTEGER NOT NULL DEFAULT 1 CHECK(vbt_dirty IN (0, 1)), + FOREIGN KEY (profile_id) REFERENCES UserProfile(id) ON DELETE CASCADE + )""", + """CREATE TABLE PendingProfileLocalCleanup ( + profile_id TEXT PRIMARY KEY NOT NULL, + enqueued_at INTEGER NOT NULL + )""", + """CREATE TABLE PendingProfileContextRecovery ( + recovery_key TEXT PRIMARY KEY NOT NULL + DEFAULT 'active_profile_transition' + CHECK(recovery_key = 'active_profile_transition'), + prior_profile_id TEXT NOT NULL, + created_profile_id TEXT, + enqueued_at INTEGER NOT NULL + )""", + """INSERT OR IGNORE INTO UserProfilePreferences(profile_id) + SELECT id FROM UserProfile""", + ) + else -> emptyList() } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/local/SchemaManifest.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/local/SchemaManifest.kt index 6f8fb943e..f8cc59202 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/local/SchemaManifest.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/local/SchemaManifest.kt @@ -211,6 +211,93 @@ internal fun reconcileFullSchema(driver: SqlDriver): SchemaReconciliationReport // ============================================================ internal val manifestTables: List = listOf( + // UserProfile -- initial schema, full current shape + // Columns added by later migrations: subscription fields (m5) + SchemaTableOperation( + table = "UserProfile", + createSql = """ + CREATE TABLE IF NOT EXISTS UserProfile ( + id TEXT PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + colorIndex INTEGER NOT NULL DEFAULT 0, + createdAt INTEGER NOT NULL, + isActive INTEGER NOT NULL DEFAULT 0, + supabase_user_id TEXT, + subscription_status TEXT DEFAULT 'free', + subscription_expires_at INTEGER, + last_auth_at INTEGER + ) + """.trimIndent(), + ), + + // UserProfilePreferences -- migration 42, device-local profile preferences. + SchemaTableOperation( + table = "UserProfilePreferences", + createSql = """ + CREATE TABLE IF NOT EXISTS UserProfilePreferences ( + profile_id TEXT PRIMARY KEY NOT NULL, + schema_version INTEGER NOT NULL DEFAULT 1, + legacy_migration_version INTEGER NOT NULL DEFAULT 0, + body_weight_kg REAL NOT NULL DEFAULT 0 CHECK(body_weight_kg = 0 OR body_weight_kg BETWEEN 20 AND 300), + weight_unit TEXT NOT NULL DEFAULT 'LB' CHECK(weight_unit IN ('KG', 'LB')), + weight_increment REAL NOT NULL DEFAULT -1 CHECK(weight_increment = -1 OR weight_increment > 0), + core_updated_at INTEGER NOT NULL DEFAULT 0, + core_local_generation INTEGER NOT NULL DEFAULT 0 CHECK(core_local_generation >= 0), + core_server_revision INTEGER NOT NULL DEFAULT 0 CHECK(core_server_revision >= 0), + core_dirty INTEGER NOT NULL DEFAULT 1 CHECK(core_dirty IN (0, 1)), + equipment_rack_json TEXT NOT NULL DEFAULT '{"version":1,"items":[]}', + rack_updated_at INTEGER NOT NULL DEFAULT 0, + rack_local_generation INTEGER NOT NULL DEFAULT 0 CHECK(rack_local_generation >= 0), + rack_server_revision INTEGER NOT NULL DEFAULT 0 CHECK(rack_server_revision >= 0), + rack_dirty INTEGER NOT NULL DEFAULT 1 CHECK(rack_dirty IN (0, 1)), + workout_preferences_json TEXT NOT NULL DEFAULT '{"version":1}', + workout_updated_at INTEGER NOT NULL DEFAULT 0, + workout_local_generation INTEGER NOT NULL DEFAULT 0 CHECK(workout_local_generation >= 0), + workout_server_revision INTEGER NOT NULL DEFAULT 0 CHECK(workout_server_revision >= 0), + workout_dirty INTEGER NOT NULL DEFAULT 1 CHECK(workout_dirty IN (0, 1)), + led_color_scheme_id INTEGER NOT NULL DEFAULT 0 CHECK(led_color_scheme_id >= 0), + led_preferences_json TEXT NOT NULL DEFAULT '{"version":1}', + led_updated_at INTEGER NOT NULL DEFAULT 0, + led_local_generation INTEGER NOT NULL DEFAULT 0 CHECK(led_local_generation >= 0), + led_server_revision INTEGER NOT NULL DEFAULT 0 CHECK(led_server_revision >= 0), + led_dirty INTEGER NOT NULL DEFAULT 1 CHECK(led_dirty IN (0, 1)), + vbt_enabled INTEGER NOT NULL DEFAULT 1 CHECK(vbt_enabled IN (0, 1)), + vbt_preferences_json TEXT NOT NULL DEFAULT '{"version":1}', + vbt_updated_at INTEGER NOT NULL DEFAULT 0, + vbt_local_generation INTEGER NOT NULL DEFAULT 0 CHECK(vbt_local_generation >= 0), + vbt_server_revision INTEGER NOT NULL DEFAULT 0 CHECK(vbt_server_revision >= 0), + vbt_dirty INTEGER NOT NULL DEFAULT 1 CHECK(vbt_dirty IN (0, 1)), + FOREIGN KEY (profile_id) REFERENCES UserProfile(id) ON DELETE CASCADE + ) + """.trimIndent(), + ), + + // PendingProfileContextRecovery -- migration 42, device-local transition journal. + SchemaTableOperation( + table = "PendingProfileContextRecovery", + createSql = """ + CREATE TABLE IF NOT EXISTS PendingProfileContextRecovery ( + recovery_key TEXT PRIMARY KEY NOT NULL + DEFAULT 'active_profile_transition' + CHECK(recovery_key = 'active_profile_transition'), + prior_profile_id TEXT NOT NULL, + created_profile_id TEXT, + enqueued_at INTEGER NOT NULL + ) + """.trimIndent(), + ), + + // PendingProfileLocalCleanup -- migration 42, device-local cleanup journal. + SchemaTableOperation( + table = "PendingProfileLocalCleanup", + createSql = """ + CREATE TABLE IF NOT EXISTS PendingProfileLocalCleanup ( + profile_id TEXT PRIMARY KEY NOT NULL, + enqueued_at INTEGER NOT NULL + ) + """.trimIndent(), + ), + // EarnedBadge -- originally bootstrapped by ensureGamificationTablesExist() // Full current shape: sync fields (m11), profile_id (m22) SchemaTableOperation( @@ -1095,25 +1182,6 @@ internal val manifestTables: List = listOf( """.trimIndent(), ), - // UserProfile -- initial schema, full current shape - // Columns added by later migrations: subscription fields (m5) - SchemaTableOperation( - table = "UserProfile", - createSql = """ - CREATE TABLE IF NOT EXISTS UserProfile ( - id TEXT PRIMARY KEY NOT NULL, - name TEXT NOT NULL, - colorIndex INTEGER NOT NULL DEFAULT 0, - createdAt INTEGER NOT NULL, - isActive INTEGER NOT NULL DEFAULT 0, - supabase_user_id TEXT, - subscription_status TEXT DEFAULT 'free', - subscription_expires_at INTEGER, - last_auth_at INTEGER - ) - """.trimIndent(), - ), - // SessionNotes -- introduced by migration 26.sqm (Phase 3.5). // Side-table for portal session-level notes keyed on routineSessionId. // Resolves the mobile persistence gap from audit item #2. diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/migration/MigrationManager.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/migration/MigrationManager.kt index 318696f3a..5ab98172b 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/migration/MigrationManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/migration/MigrationManager.kt @@ -6,7 +6,12 @@ import co.touchlab.kermit.Logger import com.devil.phoenixproject.data.local.ReconciliationStatus import com.devil.phoenixproject.data.local.SchemaIndexOperation import com.devil.phoenixproject.data.local.applyIndexCreate +import com.devil.phoenixproject.data.preferences.LegacyProfilePreferencesReader +import com.devil.phoenixproject.data.preferences.ProfileLocalSafetyStore +import com.devil.phoenixproject.data.preferences.ProfilePreferencesCodec import com.devil.phoenixproject.data.repository.GamificationRepository +import com.devil.phoenixproject.data.repository.ProfilePreferencesRepository +import com.devil.phoenixproject.data.repository.ProfileScopedDataMerger import com.devil.phoenixproject.data.repository.SqlDelightPersonalRecordRepository import com.devil.phoenixproject.data.repository.UserProfile import com.devil.phoenixproject.data.repository.UserProfileRepository @@ -15,10 +20,10 @@ import com.devil.phoenixproject.database.Routine import com.devil.phoenixproject.database.RoutineExercise import com.devil.phoenixproject.database.VitruvianDatabase import com.devil.phoenixproject.database.WorkoutSession -import com.devil.phoenixproject.domain.model.PRType -import com.devil.phoenixproject.domain.model.generateUUID +import com.devil.phoenixproject.domain.model.currentTimeMillis import com.devil.phoenixproject.domain.premium.RpgAttributeEngine import com.russhwolf.settings.Settings +import kotlin.coroutines.cancellation.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -27,10 +32,18 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +sealed interface RequiredMigrationState { + data object NotStarted : RequiredMigrationState + data object Applying : RequiredMigrationState + data object Ready : RequiredMigrationState + data class Failed(val message: String) : RequiredMigrationState +} + /** * Manages data migrations on app startup. * Call [checkAndRunMigrations] after Koin is initialized. @@ -38,11 +51,15 @@ import kotlinx.coroutines.sync.withLock */ class MigrationManager( private val database: VitruvianDatabase, - private val userProfileRepository: UserProfileRepository? = null, - private val gamificationRepository: GamificationRepository? = null, + private val userProfileRepository: UserProfileRepository, + private val gamificationRepository: GamificationRepository, + private val settings: Settings, + private val profilePreferencesRepository: ProfilePreferencesRepository, + private val profileLocalSafetyStore: ProfileLocalSafetyStore, + private val legacyProfilePreferencesReader: LegacyProfilePreferencesReader, + private val profileScopedDataMerger: ProfileScopedDataMerger = ProfileScopedDataMerger(database), private val driver: SqlDriver? = null, - private val settings: Settings? = null, -) { +) : RequiredMigrationGate { private val log = Logger.withTag("MigrationManager") companion object { @@ -52,13 +69,22 @@ class MigrationManager( * the stored value is less than [CURRENT_REPAIR_VERSION] the repairs execute and * the new version is persisted afterwards. */ - private const val CURRENT_REPAIR_VERSION = 1 + private const val CURRENT_REPAIR_VERSION = 2 private const val KEY_REPAIR_VERSION = "migration_repair_version" + private const val KEY_PROFILE_PREFERENCES_MIGRATION_COMPLETE = + "profile_preferences_legacy_migration_complete_v1" } private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val queries get() = database.vitruvianDatabaseQueries private val migrationMutex = Mutex() + private val requiredMigrationMutex = Mutex() + + private val _requiredMigrationState = MutableStateFlow( + RequiredMigrationState.NotStarted, + ) + override val requiredMigrationState: StateFlow = + _requiredMigrationState.asStateFlow() private val _profileScopeRepairState = MutableStateFlow(ProfileScopeRepairState.Idle) val profileScopeRepairState: StateFlow = _profileScopeRepairState.asStateFlow() @@ -109,38 +135,92 @@ class MigrationManager( val toCounts: ProfileScopedCounts, ) - private data class CanonicalPersonalRecord( - val exerciseId: String, - val exerciseName: String, - val weight: Double, - val reps: Long, - val oneRepMax: Double, - val achievedAt: Long, - val workoutMode: String, - val prType: String, - val volume: Double, - val phase: String, - val cable_count: Long? = null, - val uuid: String?, - ) - - private data class CanonicalEarnedBadge( - val badgeId: String, - val earnedAt: Long, - val celebratedAt: Long?, - ) - /** * Check for and run any pending migrations. * This should be called once on app startup. */ fun checkAndRunMigrations() { scope.launch { - runMigrationsNow() + runRequiredMigrations() + if (requiredMigrationState.value == RequiredMigrationState.Ready) { + runNonCriticalRepairsNow() + } } } suspend fun runMigrationsNow() { + runRequiredMigrations() + if (requiredMigrationState.value == RequiredMigrationState.Ready) { + runNonCriticalRepairsNow() + } + } + + suspend fun runRequiredMigrations() = requiredMigrationMutex.withLock { + if (_requiredMigrationState.value == RequiredMigrationState.Ready) return@withLock + _requiredMigrationState.value = RequiredMigrationState.Applying + try { + migrateProfilePreferences() + _requiredMigrationState.value = RequiredMigrationState.Ready + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + log.e(error) { "Required profile preference migration failed" } + _requiredMigrationState.value = RequiredMigrationState.Failed( + error.message ?: "Profile preference migration failed", + ) + } + } + + suspend fun retryRequiredMigrations() { + runRequiredMigrations() + if (requiredMigrationState.value == RequiredMigrationState.Ready) { + runNonCriticalRepairsNow() + } + } + + override suspend fun awaitRequiredMigrations() { + when (val terminal = requiredMigrationState.first { + it is RequiredMigrationState.Ready || it is RequiredMigrationState.Failed + }) { + RequiredMigrationState.Ready -> Unit + is RequiredMigrationState.Failed -> throw RequiredMigrationFailedException(terminal.message) + else -> error("Required migration gate returned a non-terminal state") + } + } + + private suspend fun migrateProfilePreferences() { + userProfileRepository.ensureDefaultProfile() + profilePreferencesRepository.seedMissingProfiles() + userProfileRepository.recoverPendingProfileTransitionForStartup() + val existingProfiles = userProfileRepository.allProfiles.value + val snapshot = legacyProfilePreferencesReader.readNormalized() + val migrationTime = currentTimeMillis() + existingProfiles.forEach { profile -> + queries.applyLegacyProfilePreferences( + profile_id = profile.id, + body_weight_kg = snapshot.core.bodyWeightKg.toDouble(), + weight_unit = snapshot.core.weightUnit.name, + weight_increment = snapshot.core.weightIncrement.toDouble(), + equipment_rack_json = ProfilePreferencesCodec.encodeRack(snapshot.rack), + workout_preferences_json = ProfilePreferencesCodec.encodeWorkout(snapshot.workout), + led_color_scheme_id = snapshot.led.colorScheme.toLong(), + led_preferences_json = ProfilePreferencesCodec.encodeLed(snapshot.led), + vbt_enabled = 1L, + vbt_preferences_json = ProfilePreferencesCodec.encodeVbt( + snapshot.vbt.copy(enabled = true), + ), + migrated_at = migrationTime, + ) + } + if (!settings.getBoolean(KEY_PROFILE_PREFERENCES_MIGRATION_COMPLETE, false)) { + profileLocalSafetyStore.copyLegacyToProfiles(existingProfiles.map { it.id }, snapshot.localSafety) + settings.putBoolean(KEY_PROFILE_PREFERENCES_MIGRATION_COMPLETE, true) + } + userProfileRepository.retryPendingLocalCleanup() + userProfileRepository.reconcileActiveProfileContext() + } + + private suspend fun runNonCriticalRepairsNow() { migrationMutex.withLock { _profileScopeRepairState.value = ProfileScopeRepairState.Applying("Running startup data repair") pendingProfileScopeRepair = null @@ -215,7 +295,7 @@ class MigrationManager( * (e.g. test environments) the repairs always run — matching the previous behaviour. */ private suspend fun runOneTimeRepairs() { - val storedVersion = settings?.getIntOrNull(KEY_REPAIR_VERSION) ?: 0 + val storedVersion = settings.getIntOrNull(KEY_REPAIR_VERSION) ?: 0 if (storedVersion >= CURRENT_REPAIR_VERSION) { log.d { "Skipping one-time data repairs — already at repair version $storedVersion" } return @@ -229,7 +309,7 @@ class MigrationManager( // Persist the completed version so this suite does not re-run next startup. runCatching { - settings?.putInt(KEY_REPAIR_VERSION, CURRENT_REPAIR_VERSION) + settings.putInt(KEY_REPAIR_VERSION, CURRENT_REPAIR_VERSION) }.onFailure { e -> log.w(e) { "Failed to persist repair version; repairs will re-run on next startup" } } @@ -301,8 +381,8 @@ class MigrationManager( ?: error("Profile-scope repair requires a SqlDriver") database.transaction { - mergePersonalRecords(context.fromProfileId, context.toProfileId, moveDriver) - mergeEarnedBadges(context.fromProfileId, context.toProfileId, moveDriver) + profileScopedDataMerger.mergePersonalRecords(context.fromProfileId, context.toProfileId) + profileScopedDataMerger.mergeEarnedBadges(context.fromProfileId, context.toProfileId) moveProfileScopedRows(moveDriver, "WorkoutSession", context.fromProfileId, context.toProfileId) moveProfileScopedRows(moveDriver, "Routine", context.fromProfileId, context.toProfileId) moveProfileScopedRows(moveDriver, "TrainingCycle", context.fromProfileId, context.toProfileId) @@ -321,29 +401,23 @@ class MigrationManager( } private suspend fun recomputeDerivedGamification(profileId: String) { - val repo = gamificationRepository ?: return - repo.updateStats(profileId) - val rpgInput = repo.getRpgInput(profileId) - repo.saveRpgProfile(RpgAttributeEngine.computeProfile(rpgInput), profileId) + gamificationRepository.updateStats(profileId) + val rpgInput = gamificationRepository.getRpgInput(profileId) + gamificationRepository.saveRpgProfile(RpgAttributeEngine.computeProfile(rpgInput), profileId) } private suspend fun refreshProfilesIfAvailable() { - userProfileRepository?.refreshProfiles() + userProfileRepository.refreshProfiles() } private suspend fun setActiveProfileInternal(profileId: String) { - val repo = userProfileRepository - if (repo != null) { - repo.setActiveProfile(profileId) - repo.refreshProfiles() - } else { - queries.setActiveProfile(profileId) - } + userProfileRepository.setActiveProfile(profileId) + userProfileRepository.refreshProfiles() } private suspend fun resolveActiveProfile(): UserProfile? { - userProfileRepository?.refreshProfiles() - userProfileRepository?.activeProfile?.value?.let { return it } + userProfileRepository.refreshProfiles() + userProfileRepository.activeProfile.value?.let { return it } val active = queries.getActiveProfile().executeAsOneOrNull() ?: return null return UserProfile( @@ -413,107 +487,6 @@ class MigrationManager( } } - private fun mergePersonalRecords(fromProfileId: String, toProfileId: String, auditDriver: SqlDriver) { - val canonicalByKey = linkedMapOf() - val allRecords = queries.selectAllRecords(profileId = fromProfileId).executeAsList() + - queries.selectAllRecords(profileId = toProfileId).executeAsList() - - allRecords.forEach { record -> - val canonical = CanonicalPersonalRecord( - exerciseId = record.exerciseId, - exerciseName = record.exerciseName, - weight = record.weight, - reps = record.reps, - oneRepMax = record.oneRepMax, - achievedAt = record.achievedAt, - workoutMode = normalizeWorkoutModeKey(record.workoutMode), - prType = record.prType, - volume = record.volume, - phase = record.phase, - cable_count = record.cable_count, - uuid = record.uuid, - ) - val key = "${canonical.exerciseId}|${canonical.workoutMode}|${canonical.prType}|${canonical.phase}" - val existing = canonicalByKey[key] - canonicalByKey[key] = when { - existing == null -> canonical - - shouldReplacePersonalRecord(canonical, existing) -> canonical.copy( - exerciseName = canonical.exerciseName.ifBlank { existing.exerciseName }, - uuid = canonical.uuid ?: existing.uuid, - ) - - else -> existing.copy( - exerciseName = existing.exerciseName.ifBlank { canonical.exerciseName }, - uuid = existing.uuid ?: canonical.uuid, - ) - } - } - - deleteProfileScopedRows(auditDriver, "PersonalRecord", fromProfileId) - deleteProfileScopedRows(auditDriver, "PersonalRecord", toProfileId) - - canonicalByKey.values.forEach { record -> - queries.upsertPR( - exerciseId = record.exerciseId, - exerciseName = record.exerciseName, - weight = record.weight, - reps = record.reps, - oneRepMax = record.oneRepMax, - achievedAt = record.achievedAt, - workoutMode = record.workoutMode, - prType = record.prType, - volume = record.volume, - phase = record.phase, - profile_id = toProfileId, - cable_count = record.cable_count, - uuid = record.uuid ?: generateUUID(), - ) - } - } - - private fun mergeEarnedBadges(fromProfileId: String, toProfileId: String, auditDriver: SqlDriver) { - val merged = linkedMapOf() - val allBadges = queries.selectAllEarnedBadges(profileId = fromProfileId).executeAsList() + - queries.selectAllEarnedBadges(profileId = toProfileId).executeAsList() - - allBadges.forEach { badge -> - val existing = merged[badge.badgeId] - val canonical = CanonicalEarnedBadge( - badgeId = badge.badgeId, - earnedAt = badge.earnedAt, - celebratedAt = badge.celebratedAt, - ) - merged[badge.badgeId] = when { - existing == null -> canonical - - canonical.earnedAt < existing.earnedAt -> canonical.copy( - celebratedAt = canonical.celebratedAt ?: existing.celebratedAt, - ) - - else -> existing.copy( - celebratedAt = existing.celebratedAt ?: canonical.celebratedAt, - ) - } - } - - deleteProfileScopedRows(auditDriver, "EarnedBadge", fromProfileId) - deleteProfileScopedRows(auditDriver, "EarnedBadge", toProfileId) - - merged.values.forEach { badge -> - auditDriver.execute( - identifier = null, - sql = "INSERT INTO EarnedBadge (badgeId, earnedAt, celebratedAt, profile_id) VALUES (?, ?, ?, ?)", - parameters = 4, - ) { - bindString(0, badge.badgeId) - bindLong(1, badge.earnedAt) - bindLong(2, badge.celebratedAt) - bindString(3, toProfileId) - } - } - } - private fun validateAndRepairPrUniqueIndex(auditDriver: SqlDriver) { val duplicateKeys = mutableListOf() auditDriver.executeQuery( @@ -670,61 +643,21 @@ class MigrationManager( private fun normalizeLegacyPersonalRecordModes() { knownProfileIds().forEach { profileId -> - val records = runCatching { queries.selectAllRecords(profileId = profileId).executeAsList() } - .getOrElse { error -> - log.e(error) { "Failed to load personal records for mode normalization (profile=$profileId)" } - return@forEach + var affectedRows = 0 + try { + database.transaction { + affectedRows = profileScopedDataMerger.normalizePersonalRecordModes(profileId) } - - var updated = 0 - var merged = 0 - database.transaction { - records.forEach { record -> - val normalizedMode = normalizeWorkoutModeKey(record.workoutMode) - if (normalizedMode == record.workoutMode) return@forEach - - val recordProfileId = record.profile_id.ifBlank { profileId } - val canonicalRecord = queries.selectPR( - exerciseId = record.exerciseId, - workoutMode = normalizedMode, - prType = record.prType, - phase = record.phase, - profileId = recordProfileId, - ).executeAsOneOrNull() - - if (canonicalRecord == null || shouldReplacePersonalRecord(record, canonicalRecord)) { - queries.upsertPR( - exerciseId = record.exerciseId, - exerciseName = record.exerciseName, - weight = record.weight, - reps = record.reps, - oneRepMax = record.oneRepMax, - achievedAt = record.achievedAt, - workoutMode = normalizedMode, - prType = record.prType, - volume = record.volume, - phase = record.phase, - profile_id = recordProfileId, - cable_count = record.cable_count, - uuid = canonicalRecord?.uuid ?: record.uuid ?: generateUUID(), - ) - updated++ - } else { - merged++ - } - - queries.deletePRByKey( - exerciseId = record.exerciseId, - workoutMode = record.workoutMode, - prType = record.prType, - phase = record.phase, - profile_id = recordProfileId, - ) + } catch (error: Throwable) { + log.e(error) { + "Failed to normalize personal record modes for profile=$profileId" } + throw error } - - if (updated > 0 || merged > 0) { - log.i { "Normalized personal record mode keys for profile=$profileId: updated $updated rows, merged $merged legacy duplicates" } + if (affectedRows > 0) { + log.i { + "Normalized personal record mode keys for profile=$profileId: affected $affectedRows rows" + } } } } @@ -884,14 +817,10 @@ class MigrationManager( // row with the same composite key (idx_pr_unique: // exerciseId+workoutMode+prType+phase+profile_id; idx_earned_badge_profile: // badgeId+profile_id), the update produces a duplicate key and aborts the - // whole repair transaction. Reuse the canonical dedup-merge helpers that the - // profile-scope move (applyProfileScopeMove) already uses — they collect - // both profiles, pick a canonical winner per key, delete both, and reinsert - // one row under the target profile. - // The dedup-merge helpers need a SqlDriver. When none was injected (the - // raw-UPDATE path was historically a no-op in that case), skip rather than - // crash — matching the prior behavior but without risking the unique-index - // abort the raw UPDATE could cause when a driver IS present. + // whole repair transaction. Reuse the shared merger from profile deletion; + // it resolves aliases before reassignment while retaining target numeric IDs, + // UUIDs, and sync metadata. A SqlDriver remains required for the orphan scan + // and derived-aggregate cleanup performed in the same transaction. val moveDriver = driver if (moveDriver == null) { log.w { "Orphaned PR repair skipped: no SqlDriver available for a safe dedup-merge" } @@ -904,8 +833,8 @@ class MigrationManager( for ((orphanProfileId, count) in orphanedCounts) { log.i { "Issue #319: Migrating $count PR records from deleted profile '$orphanProfileId' to '$targetProfileId'" } - mergePersonalRecords(orphanProfileId, targetProfileId, moveDriver) - mergeEarnedBadges(orphanProfileId, targetProfileId, moveDriver) + profileScopedDataMerger.mergePersonalRecords(orphanProfileId, targetProfileId) + profileScopedDataMerger.mergeEarnedBadges(orphanProfileId, targetProfileId) // Derived profile aggregates are recomputed after the move to avoid // carrying duplicate singleton rows across profiles. @@ -985,44 +914,6 @@ class MigrationManager( } } - private fun shouldReplacePersonalRecord( - candidate: com.devil.phoenixproject.database.PersonalRecord, - current: com.devil.phoenixproject.database.PersonalRecord, - ): Boolean = when (candidate.prType) { - PRType.MAX_VOLUME.name -> when { - candidate.volume > current.volume -> true - candidate.volume < current.volume -> false - else -> candidate.achievedAt > current.achievedAt - } - - else -> when { - candidate.weight > current.weight -> true - candidate.weight < current.weight -> false - candidate.oneRepMax > current.oneRepMax -> true - candidate.oneRepMax < current.oneRepMax -> false - else -> candidate.achievedAt > current.achievedAt - } - } - - private fun shouldReplacePersonalRecord( - candidate: CanonicalPersonalRecord, - current: CanonicalPersonalRecord, - ): Boolean = when (candidate.prType) { - PRType.MAX_VOLUME.name -> when { - candidate.volume > current.volume -> true - candidate.volume < current.volume -> false - else -> candidate.achievedAt > current.achievedAt - } - - else -> when { - candidate.weight > current.weight -> true - candidate.weight < current.weight -> false - candidate.oneRepMax > current.oneRepMax -> true - candidate.oneRepMax < current.oneRepMax -> false - else -> candidate.achievedAt > current.achievedAt - } - } - private fun resolveRoutineNameForSession( session: WorkoutSession, routineNameResolutionContext: RoutineNameResolutionContext, diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/migration/RequiredMigrationGate.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/migration/RequiredMigrationGate.kt new file mode 100644 index 000000000..6918d93d8 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/migration/RequiredMigrationGate.kt @@ -0,0 +1,11 @@ +package com.devil.phoenixproject.data.migration + +import kotlinx.coroutines.flow.StateFlow + +class RequiredMigrationFailedException(message: String) : IllegalStateException(message) + +interface RequiredMigrationGate { + val requiredMigrationState: StateFlow + + suspend fun awaitRequiredMigrations() +} diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/LegacyProfilePreferencesReader.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/LegacyProfilePreferencesReader.kt new file mode 100644 index 000000000..ae4b6541b --- /dev/null +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/LegacyProfilePreferencesReader.kt @@ -0,0 +1,361 @@ +package com.devil.phoenixproject.data.preferences + +import co.touchlab.kermit.Logger +import com.devil.phoenixproject.domain.model.CoreProfilePreferences +import com.devil.phoenixproject.domain.model.EchoLevel +import com.devil.phoenixproject.domain.model.JustLiftDefaultsDocument +import com.devil.phoenixproject.domain.model.LedPreferences +import com.devil.phoenixproject.domain.model.ProfileLocalSafetyPreferences +import com.devil.phoenixproject.domain.model.RackItem +import com.devil.phoenixproject.domain.model.RackItemBehavior +import com.devil.phoenixproject.domain.model.RackItemCategory +import com.devil.phoenixproject.domain.model.RackPreferences +import com.devil.phoenixproject.domain.model.RepCountTiming +import com.devil.phoenixproject.domain.model.ScalingBasis +import com.devil.phoenixproject.domain.model.SingleExerciseDefaultsDocument +import com.devil.phoenixproject.domain.model.VbtPreferences +import com.devil.phoenixproject.domain.model.VulgarTier +import com.devil.phoenixproject.domain.model.WeightUnit +import com.devil.phoenixproject.domain.model.WorkoutPreferences +import com.russhwolf.settings.Settings +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.jsonArray + +internal object LegacyProfilePreferenceKeys { + const val EQUIPMENT_RACK = "equipment_rack_items_v1" + const val JUST_LIFT = "just_lift_defaults" + const val EXERCISE_PREFIX = "exercise_defaults_" + const val STALL_DETECTION = "stall_detection_enabled" + const val ECHO_HARD_MIGRATION_JUST_LIFT = "echo_hard_default_migrated_just_lift" + const val ECHO_HARD_MIGRATION_EXERCISE_PREFIX = "echo_hard_default_migrated_exercise_" +} + +data class LegacyProfilePreferenceSnapshot( + val core: CoreProfilePreferences, + val rack: RackPreferences, + val workout: WorkoutPreferences, + val led: LedPreferences, + val vbt: VbtPreferences, + val localSafety: ProfileLocalSafetyPreferences, +) + +interface LegacyProfilePreferencesReader { + fun readNormalized(): LegacyProfilePreferenceSnapshot +} + +class SettingsLegacyProfilePreferencesReader( + private val preferencesManager: PreferencesManager, + private val settings: Settings, +) : LegacyProfilePreferencesReader { + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + @Serializable + private data class LegacyRackItemDocument( + val id: String? = null, + val name: String? = null, + val category: RackItemCategory = RackItemCategory.OTHER, + val weightKg: Float? = null, + val behavior: RackItemBehavior = RackItemBehavior.ADDED_RESISTANCE, + val enabled: Boolean = true, + val sortOrder: Int = 0, + val createdAt: Long? = null, + val updatedAt: Long? = null, + ) + + private data class LegacyExerciseDefaultsCandidate( + val exerciseId: String, + val document: SingleExerciseDefaultsDocument, + val key: String, + val precedence: Int, + ) + + override fun readNormalized(): LegacyProfilePreferenceSnapshot { + val legacy = preferencesManager.preferencesFlow.value + return LegacyProfilePreferenceSnapshot( + core = CoreProfilePreferences( + bodyWeightKg = normalizeBodyWeight(legacy.bodyWeightKg), + weightUnit = readEnum("weight_unit", WeightUnit.LB), + weightIncrement = normalizeWeightIncrement(legacy.weightIncrement), + ), + rack = RackPreferences(items = decodeLegacyRack(settings.getStringOrNull(LegacyProfilePreferenceKeys.EQUIPMENT_RACK))), + workout = WorkoutPreferences( + stopAtTop = legacy.stopAtTop, + beepsEnabled = legacy.beepsEnabled, + stallDetectionEnabled = settings.getBoolean( + LegacyProfilePreferenceKeys.STALL_DETECTION, + true, + ), + audioRepCountEnabled = legacy.audioRepCountEnabled, + repCountTiming = readEnum("rep_count_timing", RepCountTiming.TOP), + summaryCountdownSeconds = normalizeInt( + key = "summary_countdown_seconds", + value = legacy.summaryCountdownSeconds, + fallback = 10, + ) { it in SUMMARY_COUNTDOWN_VALUES }, + autoStartCountdownSeconds = normalizeInt( + key = "autostart_countdown_seconds", + value = legacy.autoStartCountdownSeconds, + fallback = 5, + ) { it in 2..10 }, + gamificationEnabled = legacy.gamificationEnabled, + autoStartRoutine = legacy.autoStartRoutine, + countdownBeepsEnabled = legacy.countdownBeepsEnabled, + repSoundEnabled = legacy.repSoundEnabled, + motionStartEnabled = legacy.motionStartEnabled, + weightSuggestionsEnabled = legacy.weightSuggestionsEnabled, + defaultRoutineExerciseUsePercentOfPR = legacy.defaultRoutineExerciseUsePercentOfPR, + defaultRoutineExerciseWeightPercentOfPR = normalizeInt( + key = "default_routine_exercise_weight_percent_of_pr", + value = settings.getInt("default_routine_exercise_weight_percent_of_pr", 80), + fallback = 80, + ) { it in 50..120 }, + voiceStopEnabled = legacy.voiceStopEnabled, + justLiftDefaults = decodeJustLiftDefaults(), + singleExerciseDefaults = decodeSingleExerciseDefaults(), + ), + led = LedPreferences( + colorScheme = normalizeInt( + key = "color_scheme", + value = legacy.colorScheme, + fallback = 0, + ) { it >= 0 }, + discoModeUnlocked = legacy.discoModeUnlocked, + ), + vbt = VbtPreferences( + enabled = true, + velocityLossThresholdPercent = normalizeInt( + key = "velocity_loss_threshold_percent", + value = settings.getInt("velocity_loss_threshold_percent", 20), + fallback = 20, + ) { it in 10..50 }, + autoEndOnVelocityLoss = legacy.autoEndOnVelocityLoss, + defaultScalingBasis = readEnum("default_scaling_basis", ScalingBasis.MAX_WEIGHT_PR), + verbalEncouragementEnabled = legacy.verbalEncouragementEnabled, + vulgarModeEnabled = legacy.vulgarModeEnabled, + vulgarTier = readEnum("vulgar_tier", VulgarTier.STRONG), + dominatrixModeUnlocked = legacy.dominatrixModeUnlocked, + dominatrixModeActive = legacy.dominatrixModeActive, + ), + localSafety = ProfileLocalSafetyPreferences( + safeWord = legacy.safeWord, + safeWordCalibrated = legacy.safeWordCalibrated, + adultsOnlyConfirmed = legacy.adultsOnlyConfirmed, + adultsOnlyPrompted = legacy.adultsOnlyPrompted, + ), + ) + } + + private fun normalizeBodyWeight(value: Float): Float = + value.takeIf { it.isFinite() && (it == 0f || it in 20f..300f) } + ?: normalized("body_weight_kg", "non-finite or out of range", 0f) + + private fun normalizeWeightIncrement(value: Float): Float = + value.takeIf { it.isFinite() && (it == -1f || it > 0f) } + ?: normalized("weight_increment", "non-finite or out of range", -1f) + + private fun decodeLegacyRack(raw: String?): List = buildList { + val ids = mutableSetOf() + val elements = runCatching { + raw?.let { json.parseToJsonElement(it).jsonArray } ?: JsonArray(emptyList()) + }.getOrElse { + logNormalization(LegacyProfilePreferenceKeys.EQUIPMENT_RACK, "malformed array") + JsonArray(emptyList()) + } + elements.forEach { element -> + val document = runCatching { + json.decodeFromJsonElement(element) + }.getOrNull() + val createdAt = document?.createdAt ?: 0L + val item = document?.let { value -> + val id = value.id?.takeIf(String::isNotBlank) ?: return@let null + val name = value.name?.takeIf(String::isNotBlank) ?: return@let null + val weightKg = value.weightKg?.takeIf { it.isFinite() && it >= 0f } + ?: return@let null + RackItem( + id = id, + name = name, + category = value.category, + weightKg = weightKg, + behavior = value.behavior, + enabled = value.enabled, + sortOrder = value.sortOrder, + createdAt = createdAt, + updatedAt = value.updatedAt ?: createdAt, + ) + } + val accepted = item?.takeIf { ids.add(it.id) } + if (accepted == null) { + logNormalization(LegacyProfilePreferenceKeys.EQUIPMENT_RACK, "invalid item") + } else { + add(accepted) + } + } + } + + private fun decodeJustLiftDefaults(): JustLiftDefaultsDocument { + val defaults = JustLiftDefaultsDocument() + val raw = settings.getStringOrNull(LegacyProfilePreferenceKeys.JUST_LIFT) ?: return defaults + val decoded = runCatching { json.decodeFromString(raw) } + .getOrElse { + logNormalization(LegacyProfilePreferenceKeys.JUST_LIFT, "malformed defaults") + return defaults + } + return JustLiftDefaultsDocument( + workoutModeId = normalizeInt( + LegacyProfilePreferenceKeys.JUST_LIFT, + decoded.workoutModeId, + defaults.workoutModeId, + ) { it in WORKOUT_MODE_IDS }, + weightPerCableKg = normalizeFloat( + LegacyProfilePreferenceKeys.JUST_LIFT, + decoded.weightPerCableKg, + defaults.weightPerCableKg, + ) { it >= 0f }, + weightChangePerRep = normalizeFloat( + LegacyProfilePreferenceKeys.JUST_LIFT, + decoded.weightChangePerRep, + defaults.weightChangePerRep, + ) { true }, + eccentricLoadPercentage = normalizeInt( + LegacyProfilePreferenceKeys.JUST_LIFT, + decoded.eccentricLoadPercentage, + defaults.eccentricLoadPercentage, + ) { it in 0..150 }, + echoLevelValue = normalizeInt( + LegacyProfilePreferenceKeys.JUST_LIFT, + normalizeLegacyEchoHardPlaceholder( + key = LegacyProfilePreferenceKeys.JUST_LIFT, + markerKey = LegacyProfilePreferenceKeys.ECHO_HARD_MIGRATION_JUST_LIFT, + value = decoded.echoLevelValue, + ), + defaults.echoLevelValue, + ) { it in 0..3 }, + stallDetectionEnabled = decoded.stallDetectionEnabled, + repCountTimingName = decoded.repCountTimingName.takeIf { name -> + RepCountTiming.entries.any { it.name == name } + } ?: normalized( + LegacyProfilePreferenceKeys.JUST_LIFT, + "unknown rep count timing", + defaults.repCountTimingName, + ), + restSeconds = normalizeInt( + LegacyProfilePreferenceKeys.JUST_LIFT, + decoded.restSeconds, + defaults.restSeconds, + ) { it == 0 || it in 5..300 }, + ) + } + + private fun decodeSingleExerciseDefaults(): Map = + settings.keys + .asSequence() + .filter { it.startsWith(LegacyProfilePreferenceKeys.EXERCISE_PREFIX) } + .mapNotNull { key -> + val decoded = runCatching { + settings.getStringOrNull(key)?.let { json.decodeFromString(it) } + }.getOrNull() + if (decoded == null || decoded.exerciseId.isBlank()) { + logNormalization(key, "malformed or invalid defaults") + null + } else { + val normalizedDefaults = decoded.copy( + echoLevelValue = normalizeLegacyEchoHardPlaceholder( + key = key, + markerKey = LegacyProfilePreferenceKeys.ECHO_HARD_MIGRATION_EXERCISE_PREFIX + decoded.exerciseId, + value = decoded.echoLevelValue, + ), + ) + val document = normalizedDefaults.toDocument() + if ( + ProfilePreferencesValidator.workout( + WorkoutPreferences(singleExerciseDefaults = mapOf(decoded.exerciseId to document)), + ).isNotEmpty() + ) { + logNormalization(key, "malformed or invalid defaults") + null + } else { + LegacyExerciseDefaultsCandidate( + exerciseId = decoded.exerciseId, + document = document, + key = key, + precedence = exerciseDefaultsKeyPrecedence(key, decoded.exerciseId), + ) + } + } + } + .sortedWith( + compareBy( + { it.exerciseId }, + { it.precedence }, + { it.key }, + ), + ) + .distinctBy { it.exerciseId } + .associate { it.exerciseId to it.document } + + private fun exerciseDefaultsKeyPrecedence(key: String, exerciseId: String): Int { + val canonicalKey = LegacyProfilePreferenceKeys.EXERCISE_PREFIX + exerciseId + return when (key) { + canonicalKey -> 0 + "${canonicalKey}_DOUBLE" -> 1 + "${canonicalKey}_SINGLE" -> 2 + "${canonicalKey}_EITHER" -> 3 + else -> 4 + } + } + + private fun normalizeInt( + key: String, + value: Int, + fallback: Int, + isValid: (Int) -> Boolean, + ): Int = value.takeIf(isValid) ?: normalized(key, "out of range", fallback) + + private fun normalizeFloat( + key: String, + value: Float, + fallback: Float, + isValid: (Float) -> Boolean, + ): Float = value.takeIf { it.isFinite() && isValid(it) } + ?: normalized(key, "non-finite or out of range", fallback) + + private inline fun > readEnum(key: String, fallback: T): T { + val raw = settings.getStringOrNull(key) ?: return fallback + return enumValues().firstOrNull { it.name == raw } + ?: normalized(key, "unknown enum", fallback) + } + + private fun normalizeLegacyEchoHardPlaceholder( + key: String, + markerKey: String, + value: Int, + ): Int = if ( + value == EchoLevel.HARD.levelValue && + !settings.getBoolean(markerKey, false) + ) { + normalized(key, "legacy HARD placeholder", EchoLevel.HARDER.levelValue) + } else { + value + } + + private fun normalized(key: String, reason: String, fallback: T): T { + logNormalization(key, reason) + return fallback + } + + private fun logNormalization(key: String, reason: String) { + Logger.w { "PROFILE_PREF_MIGRATION normalized legacy key $key: $reason" } + } + + private companion object { + val SUMMARY_COUNTDOWN_VALUES = setOf(-1, 0, 5, 10, 15, 20, 25, 30) + val WORKOUT_MODE_IDS = setOf(0, 2, 3, 4, 6, 10) + } +} diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/PreferencesManager.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/PreferencesManager.kt index 304a6b7b6..c478daff4 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/PreferencesManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/PreferencesManager.kt @@ -106,93 +106,18 @@ data class JustLiftDefaults( interface PreferencesManager { val preferencesFlow: StateFlow - suspend fun setWeightUnit(unit: WeightUnit) - - // Issue #167: setAutoplayEnabled removed - autoplay now derived from summaryCountdownSeconds - suspend fun setStopAtTop(enabled: Boolean) suspend fun setEnableVideoPlayback(enabled: Boolean) - suspend fun setBeepsEnabled(enabled: Boolean) - suspend fun setColorScheme(scheme: Int) - suspend fun setDiscoModeUnlocked(unlocked: Boolean) - suspend fun setAudioRepCountEnabled(enabled: Boolean) - suspend fun setRepCountTiming(timing: RepCountTiming) - suspend fun setSummaryCountdownSeconds(seconds: Int) - suspend fun setAutoStartCountdownSeconds(seconds: Int) - suspend fun setGamificationEnabled(enabled: Boolean) - - // Issue #266: Configurable weight increment - suspend fun setWeightIncrement(increment: Float) - - // Issue #190: Auto-start routine - suspend fun setAutoStartRoutine(enabled: Boolean) - - // Issue #229: Body weight for bodyweight exercise volume - suspend fun setBodyWeightKg(weightKg: Float) - - // Issue #100: Per-sound toggles - suspend fun setCountdownBeepsEnabled(enabled: Boolean) - suspend fun setRepSoundEnabled(enabled: Boolean) - - // Issue #237: Motion-triggered set start - suspend fun setMotionStartEnabled(enabled: Boolean) - - // Issue #293: Per-session auto-backup suspend fun setAutoBackupEnabled(enabled: Boolean) - - // Issue #293: Custom backup destination suspend fun setBackupDestination(destination: BackupDestination) - - // Issue #238: Language/locale preference suspend fun setLanguage(language: String) - - // Issue #141: Voice-activated emergency stop - suspend fun setVoiceStopEnabled(enabled: Boolean) - suspend fun setSafeWord(word: String?) - suspend fun setSafeWordCalibrated(calibrated: Boolean) - - // Issue #313: Velocity-Based Training (VBT) - suspend fun setVelocityLossThreshold(percent: Int) - suspend fun setAutoEndOnVelocityLoss(enabled: Boolean) - - // Issue #424: Suggestion-only next-set weight recommendations - suspend fun setWeightSuggestionsEnabled(enabled: Boolean) - - // Issue #517: Default scaling basis for % of 1RM routine weight resolution - suspend fun setDefaultScalingBasis(basis: ScalingBasis) - - // Issue #595: Routine-builder defaults for newly added exercises - suspend fun setDefaultRoutineExerciseUsePercentOfPR(enabled: Boolean) - suspend fun setDefaultRoutineExerciseWeightPercentOfPR(percent: Int) - - // Issue #517: Run-once flag for velocity 1RM backfill suspend fun setVelocityOneRepMaxBackfillDone(done: Boolean) - - // Issue #611: Verbal encouragement + opt-in vulgar mode + Dominatrix mode + 18+ gate - suspend fun setVerbalEncouragementEnabled(enabled: Boolean) - suspend fun setVulgarModeEnabled(enabled: Boolean) - suspend fun setVulgarTier(tier: VulgarTier) - suspend fun setDominatrixModeUnlocked(unlocked: Boolean) - suspend fun setDominatrixModeActive(active: Boolean) - suspend fun setAdultsOnlyConfirmed(confirmed: Boolean) - - // Issue #333: BLE small-MTU compatibility path (Auto/On/Off) suspend fun setBleCompatibilityMode(setting: BleCompatibilitySetting) - // Issue #611 (PR-followup #613): One-shot decline-remember gate for the 18+ - // Adults Only modal. The modal must not re-prompt on subsequent vulgar-on toggles - // once either confirm OR decline has been recorded (architecture §3 — follow - // DiscoModeUnlockDialog pattern). Booleans stored in - // KEY_ADULTS_ONLY_PROMPTED; not exposed via UserPreferences. - fun isAdultsOnlyPrompted(): Boolean - fun setAdultsOnlyPrompted(prompted: Boolean) - + @Deprecated("Legacy migration read only") suspend fun getSingleExerciseDefaults(exerciseId: String): SingleExerciseDefaults? - suspend fun saveSingleExerciseDefaults(defaults: SingleExerciseDefaults) - suspend fun clearAllSingleExerciseDefaults() + @Deprecated("Legacy migration read only") suspend fun getJustLiftDefaults(): JustLiftDefaults - suspend fun saveJustLiftDefaults(defaults: JustLiftDefaults) - suspend fun clearJustLiftDefaults() } /** @@ -223,10 +148,6 @@ class SettingsPreferencesManager(private val settings: Settings) : PreferencesMa private const val KEY_SUMMARY_COUNTDOWN_SECONDS = "summary_countdown_seconds" private const val KEY_AUTOSTART_COUNTDOWN_SECONDS = "autostart_countdown_seconds" private const val KEY_REP_COUNT_TIMING = "rep_count_timing" - private const val KEY_JUST_LIFT_DEFAULTS = "just_lift_defaults" - private const val KEY_PREFIX_EXERCISE = "exercise_defaults_" - private const val KEY_ECHO_HARD_DEFAULT_MIGRATION_JUST_LIFT = "echo_hard_default_migrated_just_lift" - private const val KEY_ECHO_HARD_DEFAULT_MIGRATION_EXERCISE_PREFIX = "echo_hard_default_migrated_exercise_" private const val KEY_GAMIFICATION_ENABLED = "gamification_enabled" private const val KEY_WEIGHT_INCREMENT = "weight_increment" private const val KEY_AUTO_START_ROUTINE = "auto_start_routine" @@ -341,14 +262,16 @@ class SettingsPreferencesManager(private val settings: Settings) : PreferencesMa _preferencesFlow.update { it.update() } } - override suspend fun setWeightUnit(unit: WeightUnit) { + // Legacy migration-source writers are intentionally internal and absent from + // PreferencesManager. They remain only for migration normalization tests. + internal suspend fun setWeightUnit(unit: WeightUnit) { settings.putString(KEY_WEIGHT_UNIT, unit.name) updateAndEmit { copy(weightUnit = unit) } } // Issue #167: setAutoplayEnabled removed - autoplay now derived from summaryCountdownSeconds - override suspend fun setStopAtTop(enabled: Boolean) { + internal suspend fun setStopAtTop(enabled: Boolean) { settings.putBoolean(KEY_STOP_AT_TOP, enabled) updateAndEmit { copy(stopAtTop = enabled) } } @@ -358,36 +281,36 @@ class SettingsPreferencesManager(private val settings: Settings) : PreferencesMa updateAndEmit { copy(enableVideoPlayback = enabled) } } - override suspend fun setBeepsEnabled(enabled: Boolean) { + internal suspend fun setBeepsEnabled(enabled: Boolean) { settings.putBoolean(KEY_BEEPS_ENABLED, enabled) updateAndEmit { copy(beepsEnabled = enabled) } } - override suspend fun setColorScheme(scheme: Int) { + internal suspend fun setColorScheme(scheme: Int) { settings.putInt(KEY_COLOR_SCHEME, scheme) updateAndEmit { copy(colorScheme = scheme) } } - override suspend fun setDiscoModeUnlocked(unlocked: Boolean) { + internal suspend fun setDiscoModeUnlocked(unlocked: Boolean) { settings.putBoolean(KEY_DISCO_MODE_UNLOCKED, unlocked) updateAndEmit { copy(discoModeUnlocked = unlocked) } } - override suspend fun setAudioRepCountEnabled(enabled: Boolean) { + internal suspend fun setAudioRepCountEnabled(enabled: Boolean) { settings.putBoolean(KEY_AUDIO_REP_COUNT, enabled) updateAndEmit { copy(audioRepCountEnabled = enabled) } } - override suspend fun setRepCountTiming(timing: RepCountTiming) { + internal suspend fun setRepCountTiming(timing: RepCountTiming) { settings.putString(KEY_REP_COUNT_TIMING, timing.name) updateAndEmit { copy(repCountTiming = timing) } } - override suspend fun setSummaryCountdownSeconds(seconds: Int) { + internal suspend fun setSummaryCountdownSeconds(seconds: Int) { settings.putInt(KEY_SUMMARY_COUNTDOWN_SECONDS, seconds) updateAndEmit { copy(summaryCountdownSeconds = seconds) } } - override suspend fun setAutoStartCountdownSeconds(seconds: Int) { + internal suspend fun setAutoStartCountdownSeconds(seconds: Int) { settings.putInt(KEY_AUTOSTART_COUNTDOWN_SECONDS, seconds) updateAndEmit { copy(autoStartCountdownSeconds = seconds) } } @@ -398,8 +321,9 @@ class SettingsPreferencesManager(private val settings: Settings) : PreferencesMa settings.putBoolean(KEY_PERMISSIONS_ONBOARDING_SHOWN, shown) } + @Deprecated("Legacy migration read only") override suspend fun getSingleExerciseDefaults(exerciseId: String): SingleExerciseDefaults? { - val key = "$KEY_PREFIX_EXERCISE$exerciseId" + val key = "${LegacyProfilePreferenceKeys.EXERCISE_PREFIX}$exerciseId" // Try new key format first var jsonString = settings.getStringOrNull(key) @@ -408,7 +332,7 @@ class SettingsPreferencesManager(private val settings: Settings) : PreferencesMa if (jsonString == null) { val legacyCableConfigs = listOf("DOUBLE", "SINGLE", "EITHER") for (cableConfig in legacyCableConfigs) { - val legacyKey = "${KEY_PREFIX_EXERCISE}${exerciseId}_$cableConfig" + val legacyKey = "${LegacyProfilePreferenceKeys.EXERCISE_PREFIX}${exerciseId}_$cableConfig" jsonString = settings.getStringOrNull(legacyKey) if (jsonString != null) { // Found with legacy key - migrate to new format @@ -432,22 +356,23 @@ class SettingsPreferencesManager(private val settings: Settings) : PreferencesMa } } - override suspend fun saveSingleExerciseDefaults(defaults: SingleExerciseDefaults) { + internal suspend fun saveSingleExerciseDefaults(defaults: SingleExerciseDefaults) { val normalizedDefaults = defaults.withNormalizedNonEchoEchoLevel() - val key = "$KEY_PREFIX_EXERCISE${defaults.exerciseId}" + val key = "${LegacyProfilePreferenceKeys.EXERCISE_PREFIX}${defaults.exerciseId}" settings.putString(key, json.encodeToString(normalizedDefaults)) settings.putBoolean(getEchoHardDefaultMigrationKey(defaults.exerciseId), true) } - override suspend fun clearAllSingleExerciseDefaults() { + internal suspend fun clearAllSingleExerciseDefaults() { // Get all keys and remove those starting with exercise prefix - settings.keys.filter { it.startsWith(KEY_PREFIX_EXERCISE) }.forEach { key -> + settings.keys.filter { it.startsWith(LegacyProfilePreferenceKeys.EXERCISE_PREFIX) }.forEach { key -> settings.remove(key) } } + @Deprecated("Legacy migration read only") override suspend fun getJustLiftDefaults(): JustLiftDefaults { - val jsonString = settings.getStringOrNull(KEY_JUST_LIFT_DEFAULTS) ?: return JustLiftDefaults() + val jsonString = settings.getStringOrNull(LegacyProfilePreferenceKeys.JUST_LIFT) ?: return JustLiftDefaults() return try { migrateSavedEchoHardDefault(json.decodeFromString(jsonString)) } catch (_: Exception) { @@ -455,24 +380,24 @@ class SettingsPreferencesManager(private val settings: Settings) : PreferencesMa } } - override suspend fun saveJustLiftDefaults(defaults: JustLiftDefaults) { + internal suspend fun saveJustLiftDefaults(defaults: JustLiftDefaults) { val normalizedDefaults = defaults.withNormalizedNonEchoEchoLevel() - settings.putString(KEY_JUST_LIFT_DEFAULTS, json.encodeToString(normalizedDefaults)) - settings.putBoolean(KEY_ECHO_HARD_DEFAULT_MIGRATION_JUST_LIFT, true) + settings.putString(LegacyProfilePreferenceKeys.JUST_LIFT, json.encodeToString(normalizedDefaults)) + settings.putBoolean(LegacyProfilePreferenceKeys.ECHO_HARD_MIGRATION_JUST_LIFT, true) } - override suspend fun clearJustLiftDefaults() { - settings.remove(KEY_JUST_LIFT_DEFAULTS) + internal suspend fun clearJustLiftDefaults() { + settings.remove(LegacyProfilePreferenceKeys.JUST_LIFT) } private fun migrateSavedEchoHardDefault(defaults: JustLiftDefaults): JustLiftDefaults { - if (settings.getBoolean(KEY_ECHO_HARD_DEFAULT_MIGRATION_JUST_LIFT, false)) return defaults + if (settings.getBoolean(LegacyProfilePreferenceKeys.ECHO_HARD_MIGRATION_JUST_LIFT, false)) return defaults - settings.putBoolean(KEY_ECHO_HARD_DEFAULT_MIGRATION_JUST_LIFT, true) + settings.putBoolean(LegacyProfilePreferenceKeys.ECHO_HARD_MIGRATION_JUST_LIFT, true) if (defaults.echoLevelValue != EchoLevel.HARD.levelValue) return defaults val migrated = defaults.copy(echoLevelValue = EchoLevel.HARDER.levelValue) - settings.putString(KEY_JUST_LIFT_DEFAULTS, json.encodeToString(migrated)) + settings.putString(LegacyProfilePreferenceKeys.JUST_LIFT, json.encodeToString(migrated)) return migrated } @@ -493,7 +418,7 @@ class SettingsPreferencesManager(private val settings: Settings) : PreferencesMa } private fun getEchoHardDefaultMigrationKey(exerciseId: String): String = - "$KEY_ECHO_HARD_DEFAULT_MIGRATION_EXERCISE_PREFIX$exerciseId" + "${LegacyProfilePreferenceKeys.ECHO_HARD_MIGRATION_EXERCISE_PREFIX}$exerciseId" private fun SingleExerciseDefaults.withNormalizedNonEchoEchoLevel(): SingleExerciseDefaults = if (workoutModeId != ProgramMode.Echo.modeValue && echoLevelValue == EchoLevel.HARD.levelValue) { @@ -509,37 +434,37 @@ class SettingsPreferencesManager(private val settings: Settings) : PreferencesMa this } - override suspend fun setGamificationEnabled(enabled: Boolean) { + internal suspend fun setGamificationEnabled(enabled: Boolean) { settings.putBoolean(KEY_GAMIFICATION_ENABLED, enabled) updateAndEmit { copy(gamificationEnabled = enabled) } } - override suspend fun setWeightIncrement(increment: Float) { + internal suspend fun setWeightIncrement(increment: Float) { settings.putFloat(KEY_WEIGHT_INCREMENT, increment) updateAndEmit { copy(weightIncrement = increment) } } - override suspend fun setAutoStartRoutine(enabled: Boolean) { + internal suspend fun setAutoStartRoutine(enabled: Boolean) { settings.putBoolean(KEY_AUTO_START_ROUTINE, enabled) updateAndEmit { copy(autoStartRoutine = enabled) } } - override suspend fun setBodyWeightKg(weightKg: Float) { + internal suspend fun setBodyWeightKg(weightKg: Float) { settings.putFloat(KEY_BODY_WEIGHT_KG, weightKg) updateAndEmit { copy(bodyWeightKg = weightKg) } } - override suspend fun setCountdownBeepsEnabled(enabled: Boolean) { + internal suspend fun setCountdownBeepsEnabled(enabled: Boolean) { settings.putBoolean(KEY_COUNTDOWN_BEEPS_ENABLED, enabled) updateAndEmit { copy(countdownBeepsEnabled = enabled) } } - override suspend fun setRepSoundEnabled(enabled: Boolean) { + internal suspend fun setRepSoundEnabled(enabled: Boolean) { settings.putBoolean(KEY_REP_SOUND_ENABLED, enabled) updateAndEmit { copy(repSoundEnabled = enabled) } } - override suspend fun setMotionStartEnabled(enabled: Boolean) { + internal suspend fun setMotionStartEnabled(enabled: Boolean) { settings.putBoolean(KEY_MOTION_START, enabled) updateAndEmit { copy(motionStartEnabled = enabled) } } @@ -559,12 +484,12 @@ class SettingsPreferencesManager(private val settings: Settings) : PreferencesMa updateAndEmit { copy(language = language) } } - override suspend fun setVoiceStopEnabled(enabled: Boolean) { + internal suspend fun setVoiceStopEnabled(enabled: Boolean) { settings.putBoolean(KEY_VOICE_STOP_ENABLED, enabled) updateAndEmit { copy(voiceStopEnabled = enabled) } } - override suspend fun setSafeWord(word: String?) { + internal suspend fun setSafeWord(word: String?) { if (word != null) { settings.putString(KEY_SAFE_WORD, word) } else { @@ -573,38 +498,38 @@ class SettingsPreferencesManager(private val settings: Settings) : PreferencesMa updateAndEmit { copy(safeWord = word) } } - override suspend fun setSafeWordCalibrated(calibrated: Boolean) { + internal suspend fun setSafeWordCalibrated(calibrated: Boolean) { settings.putBoolean(KEY_SAFE_WORD_CALIBRATED, calibrated) updateAndEmit { copy(safeWordCalibrated = calibrated) } } - override suspend fun setVelocityLossThreshold(percent: Int) { + internal suspend fun setVelocityLossThreshold(percent: Int) { val clamped = percent.coerceIn(10, 50) settings.putInt(KEY_VELOCITY_LOSS_THRESHOLD, clamped) updateAndEmit { copy(velocityLossThresholdPercent = clamped) } } - override suspend fun setAutoEndOnVelocityLoss(enabled: Boolean) { + internal suspend fun setAutoEndOnVelocityLoss(enabled: Boolean) { settings.putBoolean(KEY_AUTO_END_VELOCITY_LOSS, enabled) updateAndEmit { copy(autoEndOnVelocityLoss = enabled) } } - override suspend fun setWeightSuggestionsEnabled(enabled: Boolean) { + internal suspend fun setWeightSuggestionsEnabled(enabled: Boolean) { settings.putBoolean(KEY_WEIGHT_SUGGESTIONS_ENABLED, enabled) updateAndEmit { copy(weightSuggestionsEnabled = enabled) } } - override suspend fun setDefaultScalingBasis(basis: ScalingBasis) { + internal suspend fun setDefaultScalingBasis(basis: ScalingBasis) { settings.putString(KEY_DEFAULT_SCALING_BASIS, basis.name) updateAndEmit { copy(defaultScalingBasis = basis) } } - override suspend fun setDefaultRoutineExerciseUsePercentOfPR(enabled: Boolean) { + internal suspend fun setDefaultRoutineExerciseUsePercentOfPR(enabled: Boolean) { settings.putBoolean(KEY_DEFAULT_ROUTINE_EXERCISE_USE_PERCENT_OF_PR, enabled) updateAndEmit { copy(defaultRoutineExerciseUsePercentOfPR = enabled) } } - override suspend fun setDefaultRoutineExerciseWeightPercentOfPR(percent: Int) { + internal suspend fun setDefaultRoutineExerciseWeightPercentOfPR(percent: Int) { val clamped = percent.coerceIn(50, 120) settings.putInt(KEY_DEFAULT_ROUTINE_EXERCISE_WEIGHT_PERCENT_OF_PR, clamped) updateAndEmit { copy(defaultRoutineExerciseWeightPercentOfPR = clamped) } @@ -616,7 +541,7 @@ class SettingsPreferencesManager(private val settings: Settings) : PreferencesMa } // Issue #611: Verbal encouragement + opt-in vulgar mode + Dominatrix mode + 18+ gate - override suspend fun setVerbalEncouragementEnabled(enabled: Boolean) { + internal suspend fun setVerbalEncouragementEnabled(enabled: Boolean) { settings.putBoolean(KEY_VERBAL_ENCOURAGEMENT_ENABLED, enabled) // Cascade invariant: master-off forces both vulgar-off AND dominatrix-off. if (!enabled) { @@ -634,7 +559,7 @@ class SettingsPreferencesManager(private val settings: Settings) : PreferencesMa } } - override suspend fun setVulgarModeEnabled(enabled: Boolean) { + internal suspend fun setVulgarModeEnabled(enabled: Boolean) { // Cascade invariant: prompted gates first vulgar-on activation. // UI must invoke the 18+ modal flow BEFORE calling this setter with enabled=true. // Checks prompted (not confirmed) so that users who declined can still enable later. @@ -657,17 +582,17 @@ class SettingsPreferencesManager(private val settings: Settings) : PreferencesMa } } - override suspend fun setVulgarTier(tier: VulgarTier) { + internal suspend fun setVulgarTier(tier: VulgarTier) { settings.putString(KEY_VULGAR_TIER, tier.name) updateAndEmit { copy(vulgarTier = tier) } } - override suspend fun setDominatrixModeUnlocked(unlocked: Boolean) { + internal suspend fun setDominatrixModeUnlocked(unlocked: Boolean) { settings.putBoolean(KEY_DOMINATRIX_MODE_UNLOCKED, unlocked) updateAndEmit { copy(dominatrixModeUnlocked = unlocked) } } - override suspend fun setDominatrixModeActive(active: Boolean) { + internal suspend fun setDominatrixModeActive(active: Boolean) { // Cascade invariant: dominatrix-on requires vulgar-on AND adultsConfirmed AND unlocked. if (active) { val current = _preferencesFlow.value @@ -683,7 +608,7 @@ class SettingsPreferencesManager(private val settings: Settings) : PreferencesMa updateAndEmit { copy(dominatrixModeActive = active) } } - override suspend fun setAdultsOnlyConfirmed(confirmed: Boolean) { + internal suspend fun setAdultsOnlyConfirmed(confirmed: Boolean) { settings.putBoolean(KEY_ADULTS_ONLY_CONFIRMED, confirmed) // Confirmed implies prompted (the one-shot decline-remember flag is irrelevant after confirm). settings.putBoolean(KEY_ADULTS_ONLY_PROMPTED, true) @@ -696,7 +621,7 @@ class SettingsPreferencesManager(private val settings: Settings) : PreferencesMa updateAndEmit { copy(bleCompatibilityMode = setting) } } - override fun isAdultsOnlyPrompted(): Boolean = settings.getBoolean(KEY_ADULTS_ONLY_PROMPTED, false) + internal fun isAdultsOnlyPrompted(): Boolean = settings.getBoolean(KEY_ADULTS_ONLY_PROMPTED, false) /** * Issue #611 (PR-followup #613): Persist the one-shot decline-remember flag @@ -705,7 +630,7 @@ class SettingsPreferencesManager(private val settings: Settings) : PreferencesMa * [setAdultsOnlyConfirmed] which writes `KEY_ADULTS_ONLY_PROMPTED = true` on * the confirm path; this setter covers the decline path. */ - override fun setAdultsOnlyPrompted(prompted: Boolean) { + internal fun setAdultsOnlyPrompted(prompted: Boolean) { settings.putBoolean(KEY_ADULTS_ONLY_PROMPTED, prompted) updateAndEmit { copy(adultsOnlyPrompted = prompted) } } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/ProfileLocalSafetyStore.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/ProfileLocalSafetyStore.kt new file mode 100644 index 000000000..dd64861b4 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/ProfileLocalSafetyStore.kt @@ -0,0 +1,88 @@ +package com.devil.phoenixproject.data.preferences + +import com.devil.phoenixproject.domain.model.ProfileLocalSafetyPreferences +import com.russhwolf.settings.Settings + +interface ProfileLocalSafetyStore { + fun read(profileId: String): ProfileLocalSafetyPreferences + fun write(profileId: String, value: ProfileLocalSafetyPreferences) + suspend fun copyLegacyToProfiles( + profileIds: List, + value: ProfileLocalSafetyPreferences, + ) + fun delete(profileId: String) +} + +class SettingsProfileLocalSafetyStore( + private val settings: Settings, +) : ProfileLocalSafetyStore { + override fun read(profileId: String) = ProfileLocalSafetyPreferences( + safeWord = settings.getStringOrNull(key(profileId, SAFE_WORD)), + safeWordCalibrated = settings.getBoolean( + key(profileId, SAFE_WORD_CALIBRATED), + false, + ), + adultsOnlyConfirmed = settings.getBoolean( + key(profileId, ADULTS_ONLY_CONFIRMED), + false, + ), + adultsOnlyPrompted = settings.getBoolean( + key(profileId, ADULTS_ONLY_PROMPTED), + false, + ), + ) + + override fun write(profileId: String, value: ProfileLocalSafetyPreferences) { + val previous = read(profileId) + try { + writeKeys(profileId, value) + } catch (error: Exception) { + runCatching { writeKeys(profileId, previous) } + throw error + } + } + + override suspend fun copyLegacyToProfiles( + profileIds: List, + value: ProfileLocalSafetyPreferences, + ) { + profileIds.forEach { profileId -> write(profileId, value) } + } + + override fun delete(profileId: String) { + KEY_SUFFIXES.forEach { suffix -> settings.remove(key(profileId, suffix)) } + } + + private fun writeKeys(profileId: String, value: ProfileLocalSafetyPreferences) { + value.safeWord?.let { phrase -> + settings.putString(key(profileId, SAFE_WORD), phrase) + } ?: settings.remove(key(profileId, SAFE_WORD)) + settings.putBoolean( + key(profileId, SAFE_WORD_CALIBRATED), + value.safeWordCalibrated, + ) + settings.putBoolean( + key(profileId, ADULTS_ONLY_CONFIRMED), + value.adultsOnlyConfirmed, + ) + settings.putBoolean( + key(profileId, ADULTS_ONLY_PROMPTED), + value.adultsOnlyPrompted, + ) + } + + private fun key(profileId: String, suffix: String) = "profile_${profileId}_$suffix" + + private companion object { + const val SAFE_WORD = "safe_word" + const val SAFE_WORD_CALIBRATED = "safe_word_calibrated" + const val ADULTS_ONLY_CONFIRMED = "adults_only_confirmed" + const val ADULTS_ONLY_PROMPTED = "adults_only_prompted" + val KEY_SUFFIXES = listOf( + SAFE_WORD, + SAFE_WORD_CALIBRATED, + ADULTS_ONLY_CONFIRMED, + ADULTS_ONLY_PROMPTED, + ) + } +} diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/ProfilePreferencesCodec.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/ProfilePreferencesCodec.kt new file mode 100644 index 000000000..0d61eba86 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/ProfilePreferencesCodec.kt @@ -0,0 +1,120 @@ +package com.devil.phoenixproject.data.preferences + +import com.devil.phoenixproject.domain.model.LedPreferences +import com.devil.phoenixproject.domain.model.ProfilePreferenceValidity +import com.devil.phoenixproject.domain.model.RackPreferences +import com.devil.phoenixproject.domain.model.ScalingBasis +import com.devil.phoenixproject.domain.model.VbtPreferences +import com.devil.phoenixproject.domain.model.VulgarTier +import com.devil.phoenixproject.domain.model.WorkoutPreferences +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +object ProfilePreferencesCodec { + private val json = Json { + ignoreUnknownKeys = true + explicitNulls = false + encodeDefaults = true + } + + @Serializable + private data class LedPreferencesDocument( + val version: Int = 1, + val discoModeUnlocked: Boolean = false, + ) + + @Serializable + private data class VbtPreferencesDocument( + val version: Int = 1, + val velocityLossThresholdPercent: Int = 20, + val autoEndOnVelocityLoss: Boolean = false, + val defaultScalingBasis: ScalingBasis = ScalingBasis.MAX_WEIGHT_PR, + val verbalEncouragementEnabled: Boolean = false, + val vulgarModeEnabled: Boolean = false, + val vulgarTier: VulgarTier = VulgarTier.STRONG, + val dominatrixModeUnlocked: Boolean = false, + val dominatrixModeActive: Boolean = false, + ) + + fun encodeRack(value: RackPreferences): String = json.encodeToString(value) + fun encodeWorkout(value: WorkoutPreferences): String = json.encodeToString(value) + fun encodeLed(value: LedPreferences): String = json.encodeToString( + LedPreferencesDocument(value.version, value.discoModeUnlocked), + ) + fun encodeVbt(value: VbtPreferences): String = json.encodeToString( + VbtPreferencesDocument( + value.version, + value.velocityLossThresholdPercent, + value.autoEndOnVelocityLoss, + value.defaultScalingBasis, + value.verbalEncouragementEnabled, + value.vulgarModeEnabled, + value.vulgarTier, + value.dominatrixModeUnlocked, + value.dominatrixModeActive, + ), + ) + + fun decodeRack(raw: String) = decode(raw, RackPreferences(), ProfilePreferencesValidator::rack) + fun decodeWorkout(raw: String) = decode(raw, WorkoutPreferences(), ProfilePreferencesValidator::workout) + fun decodeLed(raw: String, colorScheme: Int): DecodedProfileDocument = + decode(raw, LedPreferencesDocument()) { value -> if (value.version == 1) emptyList() else listOf("version") } + .let { decoded -> + decoded.mapValue { value -> LedPreferences(value.version, colorScheme, value.discoModeUnlocked) } + } + fun decodeVbt(raw: String, enabled: Boolean): DecodedProfileDocument = + decode(raw, VbtPreferencesDocument()) { value -> + ProfilePreferencesValidator.vbt( + VbtPreferences( + version = value.version, + enabled = enabled, + velocityLossThresholdPercent = value.velocityLossThresholdPercent, + autoEndOnVelocityLoss = value.autoEndOnVelocityLoss, + defaultScalingBasis = value.defaultScalingBasis, + verbalEncouragementEnabled = value.verbalEncouragementEnabled, + vulgarModeEnabled = value.vulgarModeEnabled, + vulgarTier = value.vulgarTier, + dominatrixModeUnlocked = value.dominatrixModeUnlocked, + dominatrixModeActive = value.dominatrixModeActive, + ), + ) + }.let { decoded -> + decoded.mapValue { value -> + VbtPreferences( + value.version, enabled, value.velocityLossThresholdPercent, + value.autoEndOnVelocityLoss, value.defaultScalingBasis, + value.verbalEncouragementEnabled, value.vulgarModeEnabled, + value.vulgarTier, value.dominatrixModeUnlocked, value.dominatrixModeActive, + ) + } + } + + private inline fun decode( + raw: String, + fallback: T, + validate: (T) -> List, + ): DecodedProfileDocument = runCatching { json.decodeFromString(raw) } + .fold( + onSuccess = { value -> + val errors = validate(value) + DecodedProfileDocument( + value = if (errors.isEmpty()) value else fallback, + raw = raw, + validity = if (errors.isEmpty()) ProfilePreferenceValidity.Valid else ProfilePreferenceValidity.Invalid(errors.joinToString(",")), + ) + }, + onFailure = { error -> + DecodedProfileDocument(fallback, raw, ProfilePreferenceValidity.Invalid(error::class.simpleName ?: "decode")) + }, + ) +} + +data class DecodedProfileDocument( + val value: T, + val raw: String, + val validity: ProfilePreferenceValidity, +) { + fun mapValue(transform: (T) -> R) = DecodedProfileDocument(transform(value), raw, validity) +} diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/ProfilePreferencesValidator.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/ProfilePreferencesValidator.kt new file mode 100644 index 000000000..518a3af79 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/ProfilePreferencesValidator.kt @@ -0,0 +1,62 @@ +package com.devil.phoenixproject.data.preferences + +import com.devil.phoenixproject.domain.model.CoreProfilePreferences +import com.devil.phoenixproject.domain.model.LedPreferences +import com.devil.phoenixproject.domain.model.RackPreferences +import com.devil.phoenixproject.domain.model.RepCountTiming +import com.devil.phoenixproject.domain.model.VbtPreferences +import com.devil.phoenixproject.domain.model.WorkoutPreferences + +object ProfilePreferencesValidator { + fun core(value: CoreProfilePreferences): List = buildList { + if (!value.bodyWeightKg.isFinite() || (value.bodyWeightKg != 0f && value.bodyWeightKg !in 20f..300f)) add("bodyWeightKg") + if (!value.weightIncrement.isFinite() || (value.weightIncrement != -1f && value.weightIncrement <= 0f)) add("weightIncrement") + } + + fun rack(value: RackPreferences): List = buildList { + if (value.version != 1) add("version") + if (value.items.map { it.id }.distinct().size != value.items.size) add("duplicateRackItemId") + value.items.forEach { item -> + if (item.id.isBlank()) add("rackItem.id") + if (item.name.isBlank()) add("rackItem.name") + if (!item.weightKg.isFinite() || item.weightKg < 0f) add("rackItem.weightKg") + } + } + + fun workout(value: WorkoutPreferences): List = buildList { + if (value.version != 1) add("version") + if (value.summaryCountdownSeconds !in setOf(-1, 0, 5, 10, 15, 20, 25, 30)) add("summaryCountdownSeconds") + if (value.autoStartCountdownSeconds !in 2..10) add("autoStartCountdownSeconds") + if (value.defaultRoutineExerciseWeightPercentOfPR !in 50..120) add("defaultRoutineExerciseWeightPercentOfPR") + if (value.justLiftDefaults.restSeconds != 0 && value.justLiftDefaults.restSeconds !in 5..300) add("justLiftDefaults.restSeconds") + if (!value.justLiftDefaults.weightPerCableKg.isFinite() || value.justLiftDefaults.weightPerCableKg < 0f) add("justLiftDefaults.weightPerCableKg") + if (!value.justLiftDefaults.weightChangePerRep.isFinite()) add("justLiftDefaults.weightChangePerRep") + if (value.justLiftDefaults.workoutModeId !in setOf(0, 2, 3, 4, 6, 10)) add("justLiftDefaults.workoutModeId") + if (value.justLiftDefaults.eccentricLoadPercentage !in 0..150) add("justLiftDefaults.eccentricLoadPercentage") + if (value.justLiftDefaults.echoLevelValue !in 0..3) add("justLiftDefaults.echoLevelValue") + if (value.justLiftDefaults.repCountTimingName !in RepCountTiming.entries.map { it.name }) add("justLiftDefaults.repCountTimingName") + value.singleExerciseDefaults.forEach { (key, defaults) -> + if (key != defaults.exerciseId || key.isBlank()) add("singleExerciseDefaults.exerciseId") + if (!defaults.weightPerCableKg.isFinite() || defaults.weightPerCableKg < 0f) add("singleExerciseDefaults.weightPerCableKg") + if (!defaults.progressionKg.isFinite()) add("singleExerciseDefaults.progressionKg") + if (defaults.setWeightsPerCableKg.any { !it.isFinite() || it < 0f }) add("singleExerciseDefaults.setWeightsPerCableKg") + if (defaults.setRestSeconds.any { it != 0 && it !in 5..300 }) add("singleExerciseDefaults.setRestSeconds") + if (defaults.setReps.any { it != null && it < 0 }) add("singleExerciseDefaults.setReps") + if (defaults.workoutModeId !in setOf(0, 2, 3, 4, 6, 10)) add("singleExerciseDefaults.workoutModeId") + if (defaults.eccentricLoadPercentage !in 0..150) add("singleExerciseDefaults.eccentricLoadPercentage") + if (defaults.echoLevelValue !in 0..3) add("singleExerciseDefaults.echoLevelValue") + if (defaults.duration < 0) add("singleExerciseDefaults.duration") + if (defaults.defaultRackItemIds.any { it.isBlank() } || defaults.defaultRackItemIds.distinct().size != defaults.defaultRackItemIds.size) add("singleExerciseDefaults.defaultRackItemIds") + } + } + + fun led(value: LedPreferences): List = buildList { + if (value.version != 1) add("version") + if (value.colorScheme < 0) add("colorScheme") + } + + fun vbt(value: VbtPreferences): List = buildList { + if (value.version != 1) add("version") + if (value.velocityLossThresholdPercent !in 10..50) add("velocityLossThresholdPercent") + } +} diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/ProfileWorkoutDefaultsMapper.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/ProfileWorkoutDefaultsMapper.kt new file mode 100644 index 000000000..48a8804a0 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/ProfileWorkoutDefaultsMapper.kt @@ -0,0 +1,48 @@ +package com.devil.phoenixproject.data.preferences + +import com.devil.phoenixproject.domain.model.JustLiftDefaultsDocument +import com.devil.phoenixproject.domain.model.SingleExerciseDefaultsDocument + +internal fun SingleExerciseDefaults.toDocument() = SingleExerciseDefaultsDocument( + exerciseId = exerciseId, + setReps = setReps, + weightPerCableKg = weightPerCableKg, + setWeightsPerCableKg = setWeightsPerCableKg, + progressionKg = progressionKg, + setRestSeconds = setRestSeconds, + workoutModeId = workoutModeId, + eccentricLoadPercentage = eccentricLoadPercentage, + echoLevelValue = echoLevelValue, + duration = duration, + isAMRAP = isAMRAP, + perSetRestTime = perSetRestTime, + defaultRackItemIds = defaultRackItemIds, +) + +internal fun SingleExerciseDefaultsDocument.toLegacySingleExerciseDefaults() = SingleExerciseDefaults( + exerciseId = exerciseId, + setReps = setReps, + weightPerCableKg = weightPerCableKg, + setWeightsPerCableKg = setWeightsPerCableKg, + progressionKg = progressionKg, + setRestSeconds = setRestSeconds, + workoutModeId = workoutModeId, + eccentricLoadPercentage = eccentricLoadPercentage, + echoLevelValue = echoLevelValue, + duration = duration, + isAMRAP = isAMRAP, + perSetRestTime = perSetRestTime, + defaultRackItemIds = defaultRackItemIds, +) + +internal fun com.devil.phoenixproject.data.preferences.JustLiftDefaults.toDocument() = + JustLiftDefaultsDocument( + workoutModeId = workoutModeId, + weightPerCableKg = weightPerCableKg, + weightChangePerRep = weightChangePerRep, + eccentricLoadPercentage = eccentricLoadPercentage, + echoLevelValue = echoLevelValue, + stallDetectionEnabled = stallDetectionEnabled, + repCountTimingName = repCountTimingName, + restSeconds = restSeconds, + ) diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/AssessmentRepository.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/AssessmentRepository.kt index f2bdfce5b..bbecf5b07 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/AssessmentRepository.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/AssessmentRepository.kt @@ -13,7 +13,7 @@ data class AssessmentResultEntity( val assessmentSessionId: String?, val userOverrideKg: Float?, val createdAt: Long, - val profileId: String = "default", + val profileId: String, ) /** @@ -28,10 +28,10 @@ interface AssessmentRepository { /** * Save a raw assessment result to the database. * @param exerciseId Exercise ID - * @param estimatedOneRepMaxKg Estimated 1RM from load-velocity profiling + * @param estimatedOneRepMaxKg Estimated total 1RM from load-velocity profiling * @param loadVelocityDataJson JSON blob of load-velocity data points * @param sessionId Optional workout session ID to link - * @param userOverrideKg Optional user-provided override for the 1RM + * @param userOverrideKg Optional user-provided total-weight override for the 1RM * @param profileId Profile ID for multi-profile support * @return Row ID of the inserted assessment result */ @@ -41,7 +41,7 @@ interface AssessmentRepository { loadVelocityDataJson: String, sessionId: String?, userOverrideKg: Float? = null, - profileId: String = "default", + profileId: String, ): Long /** @@ -73,9 +73,9 @@ interface AssessmentRepository { * * @param exerciseId Exercise ID * @param exerciseName Exercise display name - * @param estimatedOneRepMaxKg Estimated 1RM from load-velocity profiling + * @param estimatedOneRepMaxKg Estimated total 1RM from load-velocity profiling * @param loadVelocityDataJson JSON blob of load-velocity data points - * @param userOverrideKg Optional user-provided override for the 1RM + * @param userOverrideKg Optional user-provided total-weight override for the 1RM * @param totalReps Total reps performed during assessment * @param durationMs Duration of assessment in milliseconds * @param weightPerCableKg Weight used per cable during assessment @@ -91,6 +91,6 @@ interface AssessmentRepository { totalReps: Int, durationMs: Long, weightPerCableKg: Float, - profileId: String = "default", + profileId: String, ): String } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/EquipmentRackRepository.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/EquipmentRackRepository.kt index 0c4755d58..583081117 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/EquipmentRackRepository.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/EquipmentRackRepository.kt @@ -1,11 +1,19 @@ package com.devil.phoenixproject.data.repository +import com.devil.phoenixproject.data.preferences.LegacyProfilePreferenceKeys import com.devil.phoenixproject.domain.model.ActiveRackSelection +import com.devil.phoenixproject.domain.model.RackPreferences import com.devil.phoenixproject.domain.model.RackItem import com.russhwolf.settings.Settings +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.serialization.SerializationException @@ -22,6 +30,76 @@ interface EquipmentRackRepository { suspend fun resolveActiveItems(selection: ActiveRackSelection): List } +class ProfileEquipmentRackRepository( + private val profiles: UserProfileRepository, + private val scope: CoroutineScope, +) : EquipmentRackRepository { + private val mutations = Mutex() + + private fun items(context: ActiveProfileContext): List = + (context as? ActiveProfileContext.Ready)?.preferences?.rack?.value?.items.orEmpty() + + override val rackItems: StateFlow> = profiles.activeProfileContext + .map(::items) + .distinctUntilChanged() + .stateIn( + scope = scope, + started = SharingStarted.Eagerly, + initialValue = items(profiles.activeProfileContext.value), + ) + + private fun ready(): ActiveProfileContext.Ready = + profiles.activeProfileContext.value as? ActiveProfileContext.Ready + ?: throw ProfileContextUnavailableException() + + private fun readyFor(expectedId: String): ActiveProfileContext.Ready { + val current = ready() + if (current.profile.id != expectedId) { + throw StaleProfileContextException(expectedId, current.profile.id) + } + return current + } + + private suspend fun mutate( + expectedId: String, + transform: (RackPreferences) -> RackPreferences, + ) = mutations.withLock { + val current = readyFor(expectedId) + profiles.updateRack(expectedId, transform(current.preferences.rack.value)) + } + + override suspend fun getItems(): List = ready().preferences.rack.value.items + + override suspend fun saveItems(items: List) { + val expectedId = ready().profile.id + mutate(expectedId) { it.copy(items = items) } + } + + override suspend fun upsert(item: RackItem) { + val expectedId = ready().profile.id + mutate(expectedId) { current -> + val items = current.items.toMutableList() + val existingIndex = items.indexOfFirst { it.id == item.id } + if (existingIndex >= 0) items[existingIndex] = item else items += item + current.copy(items = items) + } + } + + override suspend fun delete(id: String) { + val expectedId = ready().profile.id + mutate(expectedId) { current -> + current.copy(items = current.items.filterNot { it.id == id }) + } + } + + override suspend fun resolveActiveItems(selection: ActiveRackSelection): List { + val byId = getItems().filter { it.enabled }.associateBy { it.id } + return selection.distinctItemIds.mapNotNull(byId::get) + } + + fun close() = scope.cancel() +} + class SettingsEquipmentRackRepository( private val settings: Settings, ) : EquipmentRackRepository { @@ -85,6 +163,6 @@ class SettingsEquipmentRackRepository( } companion object { - const val KEY_RACK_ITEMS = "equipment_rack_items_v1" + const val KEY_RACK_ITEMS = LegacyProfilePreferenceKeys.EQUIPMENT_RACK } } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/ProfileDeletionMergePolicy.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/ProfileDeletionMergePolicy.kt new file mode 100644 index 000000000..46fa9f799 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/ProfileDeletionMergePolicy.kt @@ -0,0 +1,194 @@ +package com.devil.phoenixproject.data.repository + +data class ProfileMergePersonalRecord( + val id: Long, + val profileId: String, + val exerciseId: String, + val exerciseName: String, + val weight: Double, + val reps: Long, + val oneRepMax: Double, + val achievedAt: Long, + val workoutMode: String, + val prType: String, + val volume: Double, + val phase: String, + val updatedAt: Long?, + val serverId: String?, + val deletedAt: Long?, + val cableCount: Long?, + val uuid: String?, +) + +data class ProfileMergeEarnedBadge( + val id: Long, + val profileId: String, + val badgeId: String, + val earnedAt: Long, + val celebratedAt: Long?, + val updatedAt: Long?, + val serverId: String?, + val deletedAt: Long?, +) + +data class ProfileMergeExerciseMvt( + val exerciseId: String, + val profileId: String, + val personalMvtMs: Double, + val sampleCount: Long, + val updatedAt: Long, +) + +object ProfileDeletionMergePolicy { + val directProfileOwnedTables: Set = setOf( + "AssessmentResult", + "EarnedBadge", + "ExerciseMvt", + "ExternalActivity", + "ExternalBodyMeasurement", + "ExternalExerciseTemplate", + "ExternalExerciseTemplateMapping", + "ExternalProgram", + "ExternalRoutine", + "ExternalRoutineFolder", + "GamificationStats", + "IntegrationStatus", + "IntegrationSyncCursor", + "PendingProfileLocalCleanup", + "PersonalRecord", + "ProgressionEvent", + "Routine", + "RoutineGroup", + "RpgAttributes", + "StreakHistory", + "TrainingCycle", + "UserProfilePreferences", + "VelocityOneRepMaxEstimate", + "WorkoutSession", + ) + + fun mergePersonalRecordGroup( + records: List, + targetProfileId: String, + ): ProfileMergePersonalRecord { + require(records.isNotEmpty()) + val normalized = records + .map { it.copy(workoutMode = normalizeWorkoutModeKey(it.workoutMode)) } + .sortedWith( + compareByDescending { it.profileId == targetProfileId } + .thenBy { it.id }, + ) + val winner = normalized.reduce { current, candidate -> + if (personalRecordBeats(candidate, current, targetProfileId)) candidate else current + } + val retainedId = normalized + .filter { it.profileId == targetProfileId } + .minOfOrNull { it.id } + ?: normalized.minOf { it.id } + val fallbackName = normalized.firstOrNull { it.exerciseName.isNotBlank() }?.exerciseName.orEmpty() + val fallbackUuid = normalized.firstOrNull { + (it.id != winner.id || it.profileId != winner.profileId) && it.uuid != null + }?.uuid + return winner.copy( + id = retainedId, + profileId = targetProfileId, + exerciseName = winner.exerciseName.ifBlank { fallbackName }, + uuid = winner.uuid ?: fallbackUuid, + ) + } + + fun mergeEarnedBadgeGroup( + badges: List, + targetProfileId: String, + ): ProfileMergeEarnedBadge { + require(badges.isNotEmpty()) + val ordered = badges.sortedWith( + compareByDescending { it.profileId == targetProfileId } + .thenBy { it.id }, + ) + val donor = ordered.reduce { current, candidate -> + if (badgeMetadataBeats(candidate, current, targetProfileId)) candidate else current + } + val retainedId = badges + .filter { it.profileId == targetProfileId } + .minOfOrNull { it.id } + ?: badges.minOf { it.id } + return donor.copy( + id = retainedId, + profileId = targetProfileId, + earnedAt = badges.minOf { it.earnedAt }, + celebratedAt = badges.mapNotNull { it.celebratedAt }.minOrNull(), + ) + } + + fun mergeExerciseMvtGroup( + rows: List, + targetProfileId: String, + ): ProfileMergeExerciseMvt { + require(rows.isNotEmpty()) + val ordered = rows.sortedWith( + compareByDescending { it.profileId == targetProfileId } + .thenBy { it.profileId } + .thenBy { it.personalMvtMs } + .thenBy { it.sampleCount } + .thenBy { it.updatedAt }, + ) + val normalizedCounts = ordered.map { it to it.sampleCount.coerceAtLeast(0) } + val totalCount = normalizedCounts.sumOf { it.second } + val newest = ordered.reduce { current, candidate -> + when { + candidate.updatedAt > current.updatedAt -> candidate + candidate.updatedAt < current.updatedAt -> current + candidate.profileId == targetProfileId -> candidate + else -> current + } + } + val mergedMvt = if (totalCount == 0L) { + newest.personalMvtMs + } else { + normalizedCounts.sumOf { (row, count) -> row.personalMvtMs * count } / totalCount + } + return newest.copy( + profileId = targetProfileId, + personalMvtMs = mergedMvt, + sampleCount = totalCount, + updatedAt = rows.maxOf { it.updatedAt }, + ) + } + + private fun personalRecordBeats( + candidate: ProfileMergePersonalRecord, + current: ProfileMergePersonalRecord, + targetProfileId: String, + ): Boolean { + val candidateLive = candidate.deletedAt == null + val currentLive = current.deletedAt == null + if (candidateLive != currentLive) return candidateLive + + val comparison = if (candidate.prType == "MAX_VOLUME") { + compareValuesBy(candidate, current, { it.volume }, { it.weight }, { it.achievedAt }) + } else { + compareValuesBy(candidate, current, { it.weight }, { it.oneRepMax }, { it.achievedAt }) + } + return when { + comparison > 0 -> true + comparison < 0 -> false + else -> candidate.profileId == targetProfileId && current.profileId != targetProfileId + } + } + + private fun badgeMetadataBeats( + candidate: ProfileMergeEarnedBadge, + current: ProfileMergeEarnedBadge, + targetProfileId: String, + ): Boolean { + val candidateLive = candidate.deletedAt == null + val currentLive = current.deletedAt == null + return when { + candidateLive != currentLive -> candidateLive + candidate.earnedAt < current.earnedAt -> true + candidate.earnedAt > current.earnedAt -> false + else -> candidate.profileId == targetProfileId && current.profileId != targetProfileId + } + } +} diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/ProfilePreferencesRepository.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/ProfilePreferencesRepository.kt new file mode 100644 index 000000000..589f7931a --- /dev/null +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/ProfilePreferencesRepository.kt @@ -0,0 +1,233 @@ +package com.devil.phoenixproject.data.repository + +import app.cash.sqldelight.coroutines.asFlow +import app.cash.sqldelight.coroutines.mapToOne +import com.devil.phoenixproject.data.preferences.ProfilePreferencesCodec +import com.devil.phoenixproject.data.preferences.ProfilePreferencesValidator +import com.devil.phoenixproject.database.UserProfilePreferences as ProfilePreferencesRow +import com.devil.phoenixproject.database.VitruvianDatabase +import com.devil.phoenixproject.domain.model.CoreProfilePreferences +import com.devil.phoenixproject.domain.model.LedPreferences +import com.devil.phoenixproject.domain.model.ProfilePreferenceSection +import com.devil.phoenixproject.domain.model.ProfilePreferenceSectionName +import com.devil.phoenixproject.domain.model.ProfilePreferenceValidity +import com.devil.phoenixproject.domain.model.ProfileSectionMetadata +import com.devil.phoenixproject.domain.model.RackPreferences +import com.devil.phoenixproject.domain.model.UserProfilePreferences +import com.devil.phoenixproject.domain.model.VbtPreferences +import com.devil.phoenixproject.domain.model.WeightUnit +import com.devil.phoenixproject.domain.model.WorkoutPreferences +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +interface ProfilePreferencesRepository { + fun observe(profileId: String): Flow + suspend fun get(profileId: String): UserProfilePreferences + suspend fun seedMissingProfiles() + suspend fun insertDefaults(profileId: String) + suspend fun updateCore(profileId: String, value: CoreProfilePreferences, now: Long) + suspend fun updateRack(profileId: String, value: RackPreferences, now: Long) + suspend fun updateWorkout(profileId: String, value: WorkoutPreferences, now: Long) + suspend fun updateLed(profileId: String, value: LedPreferences, now: Long) + suspend fun updateVbt(profileId: String, value: VbtPreferences, now: Long) + suspend fun resetInvalidSection( + profileId: String, + section: ProfilePreferenceSectionName, + now: Long, + ) + suspend fun delete(profileId: String) +} + +class SqlDelightProfilePreferencesRepository( + private val database: VitruvianDatabase, +) : ProfilePreferencesRepository { + private val queries = database.vitruvianDatabaseQueries + + override fun observe(profileId: String): Flow = + queries.selectProfilePreferences(profileId) + .asFlow() + .mapToOne(Dispatchers.IO) + .map(::mapRow) + + override suspend fun get(profileId: String): UserProfilePreferences = + mapRow(queries.selectProfilePreferences(profileId).executeAsOne()) + + override suspend fun seedMissingProfiles() { + queries.seedMissingProfilePreferences() + } + + override suspend fun insertDefaults(profileId: String) { + queries.insertDefaultProfilePreferences(profileId, 1L) + } + + override suspend fun updateCore( + profileId: String, + value: CoreProfilePreferences, + now: Long, + ) { + require(ProfilePreferencesValidator.core(value).isEmpty()) + queries.updateCoreProfilePreferences( + body_weight_kg = value.bodyWeightKg.toDouble(), + weight_unit = value.weightUnit.name, + weight_increment = value.weightIncrement.toDouble(), + core_updated_at = now, + profile_id = profileId, + ) + } + + override suspend fun updateRack(profileId: String, value: RackPreferences, now: Long) { + require(ProfilePreferencesValidator.rack(value).isEmpty()) + queries.updateRackProfilePreferences( + equipment_rack_json = ProfilePreferencesCodec.encodeRack(value), + rack_updated_at = now, + profile_id = profileId, + ) + } + + override suspend fun updateWorkout( + profileId: String, + value: WorkoutPreferences, + now: Long, + ) { + require(ProfilePreferencesValidator.workout(value).isEmpty()) + queries.updateWorkoutProfilePreferences( + workout_preferences_json = ProfilePreferencesCodec.encodeWorkout(value), + workout_updated_at = now, + profile_id = profileId, + ) + } + + override suspend fun updateLed(profileId: String, value: LedPreferences, now: Long) { + require(ProfilePreferencesValidator.led(value).isEmpty()) + queries.updateLedProfilePreferences( + led_color_scheme_id = value.colorScheme.toLong(), + led_preferences_json = ProfilePreferencesCodec.encodeLed(value), + led_updated_at = now, + profile_id = profileId, + ) + } + + override suspend fun updateVbt(profileId: String, value: VbtPreferences, now: Long) { + require(ProfilePreferencesValidator.vbt(value).isEmpty()) + queries.updateVbtProfilePreferences( + vbt_enabled = if (value.enabled) 1L else 0L, + vbt_preferences_json = ProfilePreferencesCodec.encodeVbt(value), + vbt_updated_at = now, + profile_id = profileId, + ) + } + + override suspend fun resetInvalidSection( + profileId: String, + section: ProfilePreferenceSectionName, + now: Long, + ) { + when (section) { + ProfilePreferenceSectionName.CORE -> updateCore(profileId, CoreProfilePreferences(), now) + ProfilePreferenceSectionName.RACK -> updateRack(profileId, RackPreferences(), now) + ProfilePreferenceSectionName.WORKOUT -> updateWorkout(profileId, WorkoutPreferences(), now) + ProfilePreferenceSectionName.LED -> updateLed(profileId, LedPreferences(), now) + ProfilePreferenceSectionName.VBT -> updateVbt(profileId, VbtPreferences(), now) + } + } + + override suspend fun delete(profileId: String) { + queries.deleteProfilePreferences(profileId) + } + + private fun mapRow(row: ProfilePreferencesRow): UserProfilePreferences { + fun metadata( + updatedAt: Long, + generation: Long, + revision: Long, + dirty: Long, + ) = ProfileSectionMetadata(updatedAt, generation, revision, dirty == 1L) + + val storedCore = runCatching { + CoreProfilePreferences( + bodyWeightKg = row.body_weight_kg.toFloat(), + weightUnit = WeightUnit.valueOf(row.weight_unit), + weightIncrement = row.weight_increment.toFloat(), + ) + }.getOrNull() + val coreErrors = storedCore + ?.let(ProfilePreferencesValidator::core) + ?: listOf("weightUnit") + val rack = ProfilePreferencesCodec.decodeRack(row.equipment_rack_json) + val workout = ProfilePreferencesCodec.decodeWorkout(row.workout_preferences_json) + val led = ProfilePreferencesCodec.decodeLed( + row.led_preferences_json, + row.led_color_scheme_id.toInt(), + ) + val vbt = ProfilePreferencesCodec.decodeVbt( + row.vbt_preferences_json, + row.vbt_enabled == 1L, + ) + + return UserProfilePreferences( + profileId = row.profile_id, + schemaVersion = row.schema_version.toInt(), + legacyMigrationVersion = row.legacy_migration_version.toInt(), + core = ProfilePreferenceSection( + value = if (coreErrors.isEmpty()) requireNotNull(storedCore) else CoreProfilePreferences(), + validity = if (coreErrors.isEmpty()) { + ProfilePreferenceValidity.Valid + } else { + ProfilePreferenceValidity.Invalid(coreErrors.joinToString(",")) + }, + metadata = metadata( + row.core_updated_at, + row.core_local_generation, + row.core_server_revision, + row.core_dirty, + ), + ), + rack = ProfilePreferenceSection( + rack.value, + rack.raw, + rack.validity, + metadata( + row.rack_updated_at, + row.rack_local_generation, + row.rack_server_revision, + row.rack_dirty, + ), + ), + workout = ProfilePreferenceSection( + workout.value, + workout.raw, + workout.validity, + metadata( + row.workout_updated_at, + row.workout_local_generation, + row.workout_server_revision, + row.workout_dirty, + ), + ), + led = ProfilePreferenceSection( + led.value, + led.raw, + led.validity, + metadata( + row.led_updated_at, + row.led_local_generation, + row.led_server_revision, + row.led_dirty, + ), + ), + vbt = ProfilePreferenceSection( + vbt.value, + vbt.raw, + vbt.validity, + metadata( + row.vbt_updated_at, + row.vbt_local_generation, + row.vbt_server_revision, + row.vbt_dirty, + ), + ), + ) + } +} diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/ProfileScopedDataMerger.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/ProfileScopedDataMerger.kt new file mode 100644 index 000000000..039f7ca78 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/ProfileScopedDataMerger.kt @@ -0,0 +1,205 @@ +package com.devil.phoenixproject.data.repository + +import com.devil.phoenixproject.database.EarnedBadge +import com.devil.phoenixproject.database.ExerciseMvt +import com.devil.phoenixproject.database.PersonalRecord +import com.devil.phoenixproject.database.VitruvianDatabase + +class ProfileScopedDataMerger( + database: VitruvianDatabase, +) { + private val queries = database.vitruvianDatabaseQueries + + fun mergeForProfileDeletion(sourceProfileId: String, targetProfileId: String) { + mergePersonalRecords(sourceProfileId, targetProfileId) + mergeEarnedBadges(sourceProfileId, targetProfileId) + mergeExerciseMvt(sourceProfileId, targetProfileId) + resolveExternalConflicts(sourceProfileId, targetProfileId) + } + + fun mergePersonalRecords(sourceProfileId: String, targetProfileId: String) { + val records = queries.selectAllRecords(sourceProfileId).executeAsList() + + queries.selectAllRecords(targetProfileId).executeAsList() + records + .groupBy { it.normalizedMergeKey() } + .values + .filter { group -> group.any { it.profile_id == sourceProfileId } } + .forEach { group -> mergePersonalRecordGroup(group, targetProfileId) } + queries.reassignPRProfile(targetProfileId, sourceProfileId) + } + + /** Normalizes raw workout-mode aliases without replacing the retained database row. */ + fun normalizePersonalRecordModes(profileId: String): Int { + val groups = queries.selectAllRecords(profileId) + .executeAsList() + .groupBy { it.normalizedMergeKey() } + .values + .filter { group -> + group.size > 1 || group.any { + normalizeWorkoutModeKey(it.workoutMode) != it.workoutMode + } + } + groups.forEach { group -> mergePersonalRecordGroup(group, profileId) } + return groups.sumOf { it.size } + } + + fun mergeEarnedBadges(sourceProfileId: String, targetProfileId: String) { + val badges = queries.selectAllEarnedBadges(sourceProfileId).executeAsList() + + queries.selectAllEarnedBadges(targetProfileId).executeAsList() + badges + .groupBy { it.badgeId } + .values + .filter { group -> group.any { it.profile_id == sourceProfileId } } + .forEach { group -> + val merged = ProfileDeletionMergePolicy.mergeEarnedBadgeGroup( + group.map { it.toProfileMergeRow() }, + targetProfileId, + ) + group.filter { it.id != merged.id }.forEach { duplicate -> + queries.deleteEarnedBadgeById(duplicate.id) + } + queries.updateEarnedBadgeForProfileMerge( + earnedAt = merged.earnedAt, + celebratedAt = merged.celebratedAt, + updatedAt = merged.updatedAt, + serverId = merged.serverId, + deletedAt = merged.deletedAt, + targetId = merged.id, + ) + } + queries.reassignBadgeProfile(targetProfileId, sourceProfileId) + } + + fun mergeExerciseMvt(sourceProfileId: String, targetProfileId: String) { + val rows = queries.selectAllExerciseMvtByProfile(sourceProfileId).executeAsList() + + queries.selectAllExerciseMvtByProfile(targetProfileId).executeAsList() + rows + .groupBy { it.exerciseId } + .values + .filter { group -> group.any { it.profile_id == sourceProfileId } } + .forEach { group -> + val merged = ProfileDeletionMergePolicy.mergeExerciseMvtGroup( + group.map { it.toProfileMergeRow() }, + targetProfileId, + ) + val retained = group + .firstOrNull { it.profile_id == targetProfileId } + ?: group.first { it.profile_id == sourceProfileId } + group.filter { it !== retained }.forEach { duplicate -> + queries.deleteExerciseMvt(duplicate.exerciseId, duplicate.profile_id) + } + queries.updateExerciseMvtForProfileMerge( + targetProfileId = targetProfileId, + personalMvtMs = merged.personalMvtMs, + sampleCount = merged.sampleCount, + updatedAt = merged.updatedAt, + exerciseId = retained.exerciseId, + retainedProfileId = retained.profile_id, + ) + } + queries.reassignExerciseMvtProfile(targetProfileId, sourceProfileId) + } + + private fun resolveExternalConflicts(sourceProfileId: String, targetProfileId: String) { + queries.deleteConflictingSourceExternalActivities(sourceProfileId, targetProfileId) + queries.reassignExternalActivityProfile(targetProfileId, sourceProfileId) + + queries.deleteConflictingSourceExternalRoutineSets(sourceProfileId, targetProfileId) + queries.deleteConflictingSourceExternalRoutineExercises(sourceProfileId, targetProfileId) + queries.deleteConflictingSourceExternalRoutines(sourceProfileId, targetProfileId) + queries.reassignExternalRoutineProfile(targetProfileId, sourceProfileId) + + queries.deleteConflictingSourceExternalRoutineFolders(sourceProfileId, targetProfileId) + queries.reassignExternalRoutineFolderProfile(targetProfileId, sourceProfileId) + + queries.deleteConflictingSourceExternalProgramStats(sourceProfileId, targetProfileId) + queries.deleteConflictingSourceExternalPrograms(sourceProfileId, targetProfileId) + queries.reassignExternalProgramProfile(targetProfileId, sourceProfileId) + + queries.deleteConflictingSourceExternalExerciseTemplates(sourceProfileId, targetProfileId) + queries.reassignExternalExerciseTemplateProfile(targetProfileId, sourceProfileId) + + queries.deleteConflictingSourceExternalExerciseTemplateMappings(sourceProfileId, targetProfileId) + queries.reassignExternalExerciseTemplateMappingProfile(targetProfileId, sourceProfileId) + + queries.deleteConflictingSourceExternalBodyMeasurements(sourceProfileId, targetProfileId) + queries.reassignExternalBodyMeasurementProfile(targetProfileId, sourceProfileId) + } + + private fun mergePersonalRecordGroup( + group: List, + targetProfileId: String, + ) { + val merged = ProfileDeletionMergePolicy.mergePersonalRecordGroup( + group.map { it.toProfileMergeRow() }, + targetProfileId, + ) + group.filter { it.id != merged.id }.forEach { duplicate -> + queries.deletePersonalRecordById(duplicate.id) + } + queries.updatePersonalRecordForProfileMerge( + exerciseName = merged.exerciseName, + weight = merged.weight, + reps = merged.reps, + oneRepMax = merged.oneRepMax, + achievedAt = merged.achievedAt, + workoutMode = merged.workoutMode, + prType = merged.prType, + volume = merged.volume, + phase = merged.phase, + updatedAt = merged.updatedAt, + serverId = merged.serverId, + deletedAt = merged.deletedAt, + cableCount = merged.cableCount, + uuid = merged.uuid, + targetProfileId = targetProfileId, + targetId = merged.id, + ) + } + + private fun PersonalRecord.normalizedMergeKey() = listOf( + exerciseId, + normalizeWorkoutModeKey(workoutMode), + prType, + phase, + ).joinToString("\u001f") + + private fun PersonalRecord.toProfileMergeRow() = ProfileMergePersonalRecord( + id = id, + profileId = profile_id, + exerciseId = exerciseId, + exerciseName = exerciseName, + weight = weight, + reps = reps, + oneRepMax = oneRepMax, + achievedAt = achievedAt, + workoutMode = workoutMode, + prType = prType, + volume = volume, + phase = phase, + updatedAt = updatedAt, + serverId = serverId, + deletedAt = deletedAt, + cableCount = cable_count, + uuid = uuid, + ) + + private fun EarnedBadge.toProfileMergeRow() = ProfileMergeEarnedBadge( + id = id, + profileId = profile_id, + badgeId = badgeId, + earnedAt = earnedAt, + celebratedAt = celebratedAt, + updatedAt = updatedAt, + serverId = serverId, + deletedAt = deletedAt, + ) + + private fun ExerciseMvt.toProfileMergeRow() = ProfileMergeExerciseMvt( + exerciseId = exerciseId, + profileId = profile_id, + personalMvtMs = personalMvtMs, + sampleCount = sampleCount, + updatedAt = updatedAt, + ) +} diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/SqlDelightAssessmentRepository.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/SqlDelightAssessmentRepository.kt index 9b59932e5..f877f94c4 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/SqlDelightAssessmentRepository.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/SqlDelightAssessmentRepository.kt @@ -7,9 +7,14 @@ import com.devil.phoenixproject.database.VitruvianDatabase import com.devil.phoenixproject.domain.model.WorkoutSession import com.devil.phoenixproject.domain.model.currentTimeMillis import com.devil.phoenixproject.domain.model.generateUUID +import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext /** @@ -23,9 +28,11 @@ class SqlDelightAssessmentRepository( db: VitruvianDatabase, private val workoutRepository: WorkoutRepository, private val exerciseRepository: ExerciseRepository, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, ) : AssessmentRepository { private val queries = db.vitruvianDatabaseQueries + private val assessmentWriteMutex = Mutex() companion object { /** Marker used in WorkoutSession.routineName to identify assessment sessions. */ @@ -60,18 +67,22 @@ class SqlDelightAssessmentRepository( sessionId: String?, userOverrideKg: Float?, profileId: String, - ): Long = withContext(Dispatchers.IO) { - queries.insertAssessmentResult( - exerciseId = exerciseId, - estimatedOneRepMaxKg = estimatedOneRepMaxKg.toDouble(), - loadVelocityData = loadVelocityDataJson, - assessmentSessionId = sessionId, - userOverrideKg = userOverrideKg?.toDouble(), - createdAt = currentTimeMillis(), - profile_id = profileId, - ) - // Return the last inserted row ID - queries.lastInsertRowId().executeAsOne() + ): Long { + require(profileId.isNotBlank()) { "Assessment profileId must not be blank" } + return assessmentWriteMutex.withLock { + withContext(ioDispatcher) { + queries.insertAssessmentResult( + exerciseId = exerciseId, + estimatedOneRepMaxKg = estimatedOneRepMaxKg.toDouble(), + loadVelocityData = loadVelocityDataJson, + assessmentSessionId = sessionId, + userOverrideKg = userOverrideKg?.toDouble(), + createdAt = currentTimeMillis(), + profile_id = profileId, + ) + queries.lastInsertRowId().executeAsOne() + } + } } override fun getAssessmentsByExercise(exerciseId: String, profileId: String): Flow> = queries.selectAssessmentsByExercise( @@ -80,9 +91,9 @@ class SqlDelightAssessmentRepository( mapper = ::mapToEntity, ) .asFlow() - .mapToList(Dispatchers.IO) + .mapToList(ioDispatcher) - override suspend fun getLatestAssessment(exerciseId: String, profileId: String): AssessmentResultEntity? = withContext(Dispatchers.IO) { + override suspend fun getLatestAssessment(exerciseId: String, profileId: String): AssessmentResultEntity? = withContext(ioDispatcher) { queries.selectLatestAssessment( exerciseId, profileId = profileId, @@ -91,7 +102,7 @@ class SqlDelightAssessmentRepository( } override suspend fun deleteAssessment(id: Long) { - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { queries.deleteAssessmentResult(id) } } @@ -106,68 +117,81 @@ class SqlDelightAssessmentRepository( durationMs: Long, weightPerCableKg: Float, profileId: String, - ): String = withContext(Dispatchers.IO) { - val sessionId = generateUUID() + ): String { + require(profileId.isNotBlank()) { "Assessment profileId must not be blank" } + return assessmentWriteMutex.withLock { + withContext(ioDispatcher) { + val finalOneRepMaxTotalKg = userOverrideKg ?: estimatedOneRepMaxKg + val attemptedOneRepMaxPerCableKg = finalOneRepMaxTotalKg / 2f + val sessionId = generateUUID() + val session = WorkoutSession( + id = sessionId, + timestamp = currentTimeMillis(), + mode = "OldSchool", + reps = totalReps, + weightPerCableKg = weightPerCableKg, + duration = durationMs, + totalReps = totalReps, + exerciseId = exerciseId, + exerciseName = exerciseName, + routineName = ASSESSMENT_ROUTINE_NAME, + profileId = profileId, + ) - // 1. Create WorkoutSession with __ASSESSMENT__ marker - val session = WorkoutSession( - id = sessionId, - timestamp = currentTimeMillis(), - mode = "OldSchool", - reps = totalReps, - weightPerCableKg = weightPerCableKg, - duration = durationMs, - totalReps = totalReps, - exerciseId = exerciseId, - exerciseName = exerciseName, - routineName = ASSESSMENT_ROUTINE_NAME, - profileId = profileId, - ) - workoutRepository.saveSession(session) - Logger.d { "Assessment session created: $sessionId for exercise $exerciseName" } + var insertedResultId: Long? = null + var previousOneRepMaxPerCableKg: Float? = null + var exerciseWriteAttempted = false + try { + workoutRepository.saveSession(session) + queries.insertAssessmentResult( + exerciseId = exerciseId, + estimatedOneRepMaxKg = estimatedOneRepMaxKg.toDouble(), + loadVelocityData = loadVelocityDataJson, + assessmentSessionId = sessionId, + userOverrideKg = userOverrideKg?.toDouble(), + createdAt = currentTimeMillis(), + profile_id = profileId, + ) + insertedResultId = queries.lastInsertRowId().executeAsOne() - // F015: the assessment save is one logical operation (session + result + - // 1RM update) but the three writes go through different repositories and - // are not a single DB transaction. If a later step fails after the session - // is created, compensate by deleting the session so we never persist a - // workout session with no assessment result, or a result with no 1RM - // update. Cross-repository transaction sharing isn't available here, so - // this explicit rollback is the safe equivalent. - var insertedResultId: Long? = null - try { - // 2. Insert AssessmentResult linked to the session - queries.insertAssessmentResult( - exerciseId = exerciseId, - estimatedOneRepMaxKg = estimatedOneRepMaxKg.toDouble(), - loadVelocityData = loadVelocityDataJson, - assessmentSessionId = sessionId, - userOverrideKg = userOverrideKg?.toDouble(), - createdAt = currentTimeMillis(), - profile_id = profileId, - ) - insertedResultId = queries.lastInsertRowId().executeAsOne() - Logger.d { - "Assessment result saved for exercise $exerciseName (1RM: ${estimatedOneRepMaxKg}kg)" - } + previousOneRepMaxPerCableKg = + exerciseRepository.getExerciseById(exerciseId)?.oneRepMaxKg + exerciseWriteAttempted = true + exerciseRepository.updateOneRepMax( + exerciseId, + attemptedOneRepMaxPerCableKg, + ) + Logger.d { + "Assessment saved for $exerciseName: " + + "$attemptedOneRepMaxPerCableKg kg per cable" + } + } catch (failure: Throwable) { + withContext(NonCancellable) { + if (exerciseWriteAttempted) { + runCatching { + queries.restoreOneRepMaxIfCurrent( + previousOneRepMaxKg = + previousOneRepMaxPerCableKg?.toDouble(), + exerciseId = exerciseId, + attemptedOneRepMaxKg = + attemptedOneRepMaxPerCableKg.toDouble(), + ) + } + } + insertedResultId?.let { id -> + runCatching { queries.deleteAssessmentResult(id) } + } + runCatching { workoutRepository.deleteSession(sessionId) } + } + if (failure is CancellationException) throw failure + Logger.w(failure) { + "Assessment save failed; compensated session, result, and exercise 1RM" + } + throw failure + } - // 3. Update exercise 1RM (prefer user override if provided). - // Assessment estimates are TOTAL weight (both cables); Exercise.oneRepMaxKg is per-cable. - val finalOneRepMaxTotal = userOverrideKg ?: estimatedOneRepMaxKg - val finalOneRepMaxPerCable = finalOneRepMaxTotal / 2f - exerciseRepository.updateOneRepMax(exerciseId, finalOneRepMaxPerCable) - Logger.d { "Exercise 1RM updated: $exerciseName -> ${finalOneRepMaxPerCable}kg per cable" } - } catch (e: Throwable) { - if (e is kotlinx.coroutines.CancellationException) throw e - Logger.w(e) { - "Assessment save failed after session create; rolling back session $sessionId" + sessionId } - // Remove BOTH the inserted result (if any) and the session, otherwise a - // failure between steps 2 and 3 leaves an orphaned AssessmentResult. - insertedResultId?.let { runCatching { queries.deleteAssessmentResult(it) } } - runCatching { workoutRepository.deleteSession(sessionId) } - throw e } - - sessionId } } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/SqlDelightWorkoutRepository.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/SqlDelightWorkoutRepository.kt index b73ff06bc..7b346d6b6 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/SqlDelightWorkoutRepository.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/SqlDelightWorkoutRepository.kt @@ -529,6 +529,33 @@ class SqlDelightWorkoutRepository(private val db: VitruvianDatabase, private val .asFlow() .mapToList(Dispatchers.IO) + override suspend fun getRecentCompletedSessionsForExercise( + exerciseId: String, + profileId: String, + limit: Int, + ): List { + require(exerciseId.isNotBlank()) { "exerciseId must not be blank" } + require(profileId.isNotBlank()) { "profileId must not be blank" } + require(limit in 1..MAX_RECENT_EXERCISE_SESSIONS) { + "limit must be in 1..$MAX_RECENT_EXERCISE_SESSIONS" + } + return withContext(Dispatchers.IO) { + queries.selectRecentCompletedSessionsForExercise( + profileId = profileId, + exerciseId = exerciseId, + limit = limit.toLong(), + mapper = ::mapToSession, + ).executeAsList() + } + } + + override suspend fun getMostRecentCompletedExerciseId(profileId: String): String? { + require(profileId.isNotBlank()) { "profileId must not be blank" } + return withContext(Dispatchers.IO) { + queries.selectMostRecentCompletedExerciseId(profileId).executeAsOneOrNull() + } + } + override suspend fun saveSession(session: WorkoutSession) { withContext(Dispatchers.IO) { queries.insertSession( diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/UserProfileRepository.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/UserProfileRepository.kt index 4e7a80f79..c16ff8e4f 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/UserProfileRepository.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/UserProfileRepository.kt @@ -1,14 +1,28 @@ package com.devil.phoenixproject.data.repository import co.touchlab.kermit.Logger +import com.devil.phoenixproject.data.preferences.ProfileLocalSafetyStore import com.devil.phoenixproject.database.VitruvianDatabase +import com.devil.phoenixproject.domain.model.CoreProfilePreferences +import com.devil.phoenixproject.domain.model.LedPreferences +import com.devil.phoenixproject.domain.model.ProfileLocalSafetyPreferences +import com.devil.phoenixproject.domain.model.RackPreferences +import com.devil.phoenixproject.domain.model.UserProfilePreferences +import com.devil.phoenixproject.domain.model.VbtPreferences +import com.devil.phoenixproject.domain.model.WorkoutPreferences import com.devil.phoenixproject.domain.model.currentTimeMillis import com.devil.phoenixproject.domain.model.generateUUID import com.devil.phoenixproject.domain.premium.RpgAttributeEngine +import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext enum class SubscriptionStatus { FREE, @@ -35,34 +49,88 @@ data class UserProfile( val colorIndex: Int, val createdAt: Long, val isActive: Boolean, - // Subscription fields val supabaseUserId: String? = null, val subscriptionStatus: SubscriptionStatus = SubscriptionStatus.FREE, val subscriptionExpiresAt: Long? = null, val lastAuthAt: Long? = null, ) +sealed interface ActiveProfileContext { + data class Switching(val targetProfileId: String?) : ActiveProfileContext + + data class Ready( + val profile: UserProfile, + val preferences: UserProfilePreferences, + val localSafety: ProfileLocalSafetyPreferences, + ) : ActiveProfileContext +} + +class ProfileContextUnavailableException : IllegalStateException( + "Active profile context is switching", +) + +class StaleProfileContextException( + expectedProfileId: String, + activeProfileId: String, +) : IllegalStateException( + "Profile changed from $expectedProfileId to $activeProfileId before the update completed", +) + +class ProfileContextRecoveryException(cause: Throwable) : IllegalStateException( + "Could not reconcile the active profile context", + cause, +) + interface UserProfileRepository { val activeProfile: StateFlow val allProfiles: StateFlow> + val activeProfileContext: StateFlow + fun observePreferences(profileId: String): Flow suspend fun createProfile(name: String, colorIndex: Int): UserProfile + suspend fun createAndActivateProfile(name: String, colorIndex: Int): UserProfile suspend fun updateProfile(id: String, name: String, colorIndex: Int) suspend fun deleteProfile(id: String): Boolean + suspend fun deleteActiveProfile(expectedProfileId: String): Boolean suspend fun setActiveProfile(id: String) suspend fun refreshProfiles() suspend fun ensureDefaultProfile() + suspend fun updateCore(profileId: String, value: CoreProfilePreferences) + /** Atomically transforms the latest active core section after validating [profileId]. */ + suspend fun mutateCore( + profileId: String, + transform: (CoreProfilePreferences) -> CoreProfilePreferences, + ) + suspend fun updateRack(profileId: String, value: RackPreferences) + suspend fun updateWorkout(profileId: String, value: WorkoutPreferences) + suspend fun updateLed(profileId: String, value: LedPreferences) + suspend fun updateVbt(profileId: String, value: VbtPreferences) + suspend fun updateLocalSafety(profileId: String, value: ProfileLocalSafetyPreferences) + suspend fun retryPendingLocalCleanup(profileId: String? = null) + suspend fun recoverPendingProfileTransitionForStartup() + suspend fun reconcileActiveProfileContext() - // Subscription methods suspend fun linkToSupabase(profileId: String, supabaseUserId: String) - suspend fun updateSubscriptionStatus(profileId: String, status: SubscriptionStatus, expiresAt: Long?) + suspend fun updateSubscriptionStatus( + profileId: String, + status: SubscriptionStatus, + expiresAt: Long?, + ) suspend fun getProfileBySupabaseId(supabaseUserId: String): UserProfile? fun getActiveProfileSubscriptionStatus(): Flow } -class SqlDelightUserProfileRepository(private val database: VitruvianDatabase) : UserProfileRepository { - +class SqlDelightUserProfileRepository( + private val database: VitruvianDatabase, + private val profilePreferencesRepository: ProfilePreferencesRepository, + private val profileLocalSafetyStore: ProfileLocalSafetyStore, + private val gamificationRepository: GamificationRepository, + private val profileScopedDataMerger: ProfileScopedDataMerger = ProfileScopedDataMerger(database), + private val beforeProfileDeletionCommit: () -> Unit = {}, +) : UserProfileRepository { private val queries = database.vitruvianDatabaseQueries + private val profileContextMutex = Mutex() + private val profileCleanupMutex = Mutex() private val _activeProfile = MutableStateFlow(null) override val activeProfile: StateFlow = _activeProfile.asStateFlow() @@ -70,116 +138,454 @@ class SqlDelightUserProfileRepository(private val database: VitruvianDatabase) : private val _allProfiles = MutableStateFlow>(emptyList()) override val allProfiles: StateFlow> = _allProfiles.asStateFlow() + private val _activeProfileContext = MutableStateFlow( + ActiveProfileContext.Switching(null), + ) + override val activeProfileContext: StateFlow = + _activeProfileContext.asStateFlow() + init { - // Ensure default profile exists and refresh profiles on initialization ensureDefaultProfileSync() + _activeProfileContext.value = ActiveProfileContext.Switching(activeProfile.value?.id) } - private fun ensureDefaultProfileSync() { - val count = queries.countProfiles().executeAsOne() - if (count == 0L) { - queries.insertProfile( - id = "default", - name = "Default", - colorIndex = 0L, - createdAt = currentTimeMillis(), - isActive = 1L, - ) + override fun observePreferences(profileId: String): Flow = + profilePreferencesRepository.observe(profileId) + + override suspend fun createProfile(name: String, colorIndex: Int): UserProfile = + profileContextMutex.withLock { + val trimmedName = name.trim() + require(trimmedName.isNotEmpty()) { "Profile name must not be blank" } + val id = generateUUID() + val createdAt = currentTimeMillis() + database.transaction { + queries.insertProfile(id, trimmedName, colorIndex.toLong(), createdAt, 0L) + queries.insertDefaultProfilePreferences(id, 1L) + } + refreshProfilesSync() + requireNotNull(allProfiles.value.firstOrNull { it.id == id }) { + "Created profile missing: $id" + } } - refreshProfilesSync() - // If no profile is active (e.g., after a migration or data corruption), - // activate an existing profile so data filtered by profile_id remains visible. - if (_activeProfile.value == null && _allProfiles.value.isNotEmpty()) { - // Prefer "default" if it exists; otherwise activate the first available profile. - val targetId = _allProfiles.value.firstOrNull { it.id == "default" }?.id - ?: _allProfiles.value.first().id - queries.setActiveProfile(targetId) + + override suspend fun createAndActivateProfile( + name: String, + colorIndex: Int, + ): UserProfile = profileContextMutex.withLock { + val trimmedName = name.trim() + require(trimmedName.isNotEmpty()) { "Profile name must not be blank" } + val id = generateUUID() + val createdAt = currentTimeMillis() + withProfileContextTransition(id) { previous -> + database.transaction { + queries.insertProfile(id, trimmedName, colorIndex.toLong(), createdAt, 0L) + queries.insertDefaultProfilePreferences(id, 1L) + queries.enqueueProfileContextRecovery(previous.profile.id, id, createdAt) + queries.setActiveProfile(id) + } refreshProfilesSync() + publishReadyContext(id) + val created = activeProfile.value ?: error("Activated profile missing: $id") + queries.clearPendingProfileContextRecovery() + created } } - private fun refreshProfilesSync() { - val profiles = queries.getAllProfiles().executeAsList().map { it.toUserProfile() } - _allProfiles.value = profiles - _activeProfile.value = profiles.find { it.isActive } + override suspend fun updateProfile(id: String, name: String, colorIndex: Int) { + profileContextMutex.withLock { + val trimmedName = name.trim() + require(trimmedName.isNotEmpty()) { "Profile name must not be blank" } + queries.updateProfile(trimmedName, colorIndex.toLong(), id) + refreshProfilesSync() + if (activeProfile.value?.id == id && + _activeProfileContext.value is ActiveProfileContext.Ready + ) { + publishReadyContext(id) + } + } } - override suspend fun refreshProfiles() { - refreshProfilesSync() + override suspend fun deleteProfile(id: String): Boolean = profileContextMutex.withLock { + deleteProfileLocked(id, requireActive = false) } - override suspend fun ensureDefaultProfile() { - ensureDefaultProfileSync() - } + override suspend fun deleteActiveProfile(expectedProfileId: String): Boolean = + profileContextMutex.withLock { + val ready = _activeProfileContext.value as? ActiveProfileContext.Ready + ?: throw ProfileContextUnavailableException() + if (ready.profile.id != expectedProfileId) { + throw StaleProfileContextException(expectedProfileId, ready.profile.id) + } + deleteProfileLocked(expectedProfileId, requireActive = true) + } - override suspend fun createProfile(name: String, colorIndex: Int): UserProfile { - val id = generateUUID() - val createdAt = currentTimeMillis() - queries.insertProfile(id, name, colorIndex.toLong(), createdAt, 0L) - refreshProfilesSync() - return UserProfile(id, name, colorIndex, createdAt, false) - } + private suspend fun deleteProfileLocked(id: String, requireActive: Boolean): Boolean { + if (id == DEFAULT_PROFILE_ID) return false - override suspend fun updateProfile(id: String, name: String, colorIndex: Int) { - queries.updateProfile(name, colorIndex.toLong(), id) - refreshProfilesSync() - } + val previous = _activeProfileContext.value as? ActiveProfileContext.Ready + ?: throw ProfileContextUnavailableException() + if (requireActive && previous.profile.id != id) { + throw StaleProfileContextException(id, previous.profile.id) + } + if (queries.getProfileById(id).executeAsOneOrNull() == null) return false - override suspend fun deleteProfile(id: String): Boolean { - if (id == "default") return false - val wasActive = _activeProfile.value?.id == id - val targetProfileId = if (wasActive) "default" else (_activeProfile.value?.id ?: "default") - val gamificationRepository = SqlDelightGamificationRepository(database) - - // Cascade: reassign all data from the deleted profile to the target profile - Logger.i { "PROFILE_DELETE: Reassigning data from profile '$id' to '$targetProfileId'" } - database.transaction { - queries.reassignRoutineProfile(targetProfileId, id) - queries.reassignSessionProfile(targetProfileId, id) - queries.reassignPRProfile(targetProfileId, id) - queries.reassignTrainingCycleProfile(targetProfileId, id) - queries.reassignBadgeProfile(targetProfileId, id) - queries.reassignStreakProfile(targetProfileId, id) - queries.deleteGamificationStatsByProfile(id) - queries.deleteGamificationStatsByProfile(targetProfileId) - queries.deleteRpgAttributesByProfile(id) - queries.deleteRpgAttributesByProfile(targetProfileId) - queries.reassignAssessmentResultProfile(targetProfileId, id) - queries.reassignProgressionProfile(targetProfileId, id) - } - - queries.deleteProfile(id) + val wasActive = previous.profile.id == id + val targetProfileId = if (requireActive || wasActive) DEFAULT_PROFILE_ID else previous.profile.id + requireNotNull(queries.getProfileById(targetProfileId).executeAsOneOrNull()) { + "Profile deletion target missing: $targetProfileId" + } if (wasActive) { - queries.setActiveProfile("default") + _activeProfileContext.value = ActiveProfileContext.Switching(targetProfileId) } - // Issue #393: Wrap post-deletion gamification recompute in try-catch. - // getRpgInput() has no internal error handling; if it throws (e.g., missing - // data after cascade reassignment), the whole deleteProfile call propagates - // the exception to the UI, which on iOS causes SIGABRT. + try { + Logger.i { "PROFILE_DELETE: Reassigning data from profile '$id' to '$targetProfileId'" } + database.transaction { + queries.enqueueProfileLocalCleanup(id, currentTimeMillis()) + profileScopedDataMerger.mergeForProfileDeletion(id, targetProfileId) + queries.reassignRoutineGroupProfile(targetProfileId, id) + queries.reassignRoutineProfile(targetProfileId, id) + queries.reassignSessionProfile(targetProfileId, id) + queries.reassignTrainingCycleProfile(targetProfileId, id) + queries.reassignStreakProfile(targetProfileId, id) + queries.deleteGamificationStatsByProfile(id) + queries.deleteGamificationStatsByProfile(targetProfileId) + queries.deleteRpgAttributesByProfile(id) + queries.deleteRpgAttributesByProfile(targetProfileId) + queries.reassignAssessmentResultProfile(targetProfileId, id) + queries.reassignVelocityOneRepMaxProfile(targetProfileId, id) + queries.reassignProgressionProfile(targetProfileId, id) + queries.deleteIntegrationStatusByProfile(id) + queries.deleteIntegrationSyncCursorByProfile(id) + queries.deleteProfilePreferences(id) + queries.deleteProfile(id) + if (wasActive) queries.setActiveProfile(targetProfileId) + beforeProfileDeletionCommit() + } + } catch (failure: Throwable) { + _activeProfileContext.value = previous + throw failure + } + + try { + refreshProfilesSync() + publishReadyContext(targetProfileId) + } catch (failure: Throwable) { + _activeProfileContext.value = ActiveProfileContext.Switching(targetProfileId) + val recoveryFailure = withContext(NonCancellable) { + runCatching { + reconcileActiveProfileContextLocked(publishReady = true) + }.exceptionOrNull() + } + if (recoveryFailure != null) { + failure.addSuppressed(recoveryFailure) + throw ProfileContextRecoveryException(failure) + } + if (failure is CancellationException) throw failure + } + + retryPendingLocalCleanup(id) + try { gamificationRepository.updateStats(targetProfileId) val rpgInput = gamificationRepository.getRpgInput(targetProfileId) - gamificationRepository.saveRpgProfile(RpgAttributeEngine.computeProfile(rpgInput), targetProfileId) - } catch (e: kotlin.coroutines.cancellation.CancellationException) { - throw e - } catch (e: Exception) { - Logger.e(e) { "PROFILE_DELETE: Gamification recompute failed for profile '$targetProfileId' after deleting '$id'" } + gamificationRepository.saveRpgProfile( + RpgAttributeEngine.computeProfile(rpgInput), + targetProfileId, + ) + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + Logger.e(error) { + "PROFILE_DELETE: Gamification recompute failed for profile '$targetProfileId' after deleting '$id'" + } } - refreshProfilesSync() - // Returns true because the profile itself was deleted successfully. - // Gamification recompute failure above is best-effort and non-critical; - // stats will self-heal on next workout or app restart. return true } override suspend fun setActiveProfile(id: String) { - queries.setActiveProfile(id) + profileContextMutex.withLock { + require(allProfiles.value.any { it.id == id }) { "Unknown profile: $id" } + withProfileContextTransition(id) { previous -> + database.transaction { + queries.enqueueProfileContextRecovery( + prior_profile_id = previous.profile.id, + created_profile_id = null, + enqueued_at = currentTimeMillis(), + ) + queries.setActiveProfile(id) + } + refreshProfilesSync() + publishReadyContext(id) + queries.clearPendingProfileContextRecovery() + } + } + } + + override suspend fun refreshProfiles() { + profileContextMutex.withLock { + refreshProfilesSync() + val ready = _activeProfileContext.value as? ActiveProfileContext.Ready + if (ready != null && activeProfile.value?.id == ready.profile.id) { + publishReadyContext(ready.profile.id) + } + } + } + + override suspend fun ensureDefaultProfile() { + profileContextMutex.withLock { + ensureDefaultProfileSync() + val ready = _activeProfileContext.value as? ActiveProfileContext.Ready + if (ready != null && activeProfile.value?.id == ready.profile.id) { + publishReadyContext(ready.profile.id) + } + } + } + + override suspend fun updateCore(profileId: String, value: CoreProfilePreferences) = + mutateActiveProfile(profileId) { + profilePreferencesRepository.updateCore(profileId, value, currentTimeMillis()) + } + + override suspend fun mutateCore( + profileId: String, + transform: (CoreProfilePreferences) -> CoreProfilePreferences, + ) = mutateActiveProfile(profileId) { context -> + profilePreferencesRepository.updateCore( + profileId, + transform(context.preferences.core.value), + currentTimeMillis(), + ) + } + + override suspend fun updateRack(profileId: String, value: RackPreferences) = + mutateActiveProfile(profileId) { + profilePreferencesRepository.updateRack(profileId, value, currentTimeMillis()) + } + + override suspend fun updateWorkout(profileId: String, value: WorkoutPreferences) = + mutateActiveProfile(profileId) { + profilePreferencesRepository.updateWorkout(profileId, value, currentTimeMillis()) + } + + override suspend fun updateLed(profileId: String, value: LedPreferences) = + mutateActiveProfile(profileId) { + profilePreferencesRepository.updateLed(profileId, value, currentTimeMillis()) + } + + override suspend fun updateVbt(profileId: String, value: VbtPreferences) = + mutateActiveProfile(profileId) { + profilePreferencesRepository.updateVbt(profileId, value, currentTimeMillis()) + } + + override suspend fun updateLocalSafety( + profileId: String, + value: ProfileLocalSafetyPreferences, + ) = mutateActiveProfile(profileId) { + profileLocalSafetyStore.write(profileId, value) + } + + override suspend fun retryPendingLocalCleanup(profileId: String?) { + profileCleanupMutex.withLock { + queries.selectPendingProfileLocalCleanup().executeAsList() + .asSequence() + .filter { profileId == null || it.profile_id == profileId } + .forEach { pending -> + try { + profileLocalSafetyStore.delete(pending.profile_id) + queries.dequeueProfileLocalCleanup(pending.profile_id) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + Logger.w(error) { + "Profile local cleanup remains queued profile=${pending.profile_id}" + } + } + } + } + } + + override suspend fun recoverPendingProfileTransitionForStartup() { + profileContextMutex.withLock { + _activeProfileContext.value = ActiveProfileContext.Switching(null) + try { + reconcileActiveProfileContextLocked(publishReady = false) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + throw ProfileContextRecoveryException(error) + } + } + } + + override suspend fun reconcileActiveProfileContext() { + profileContextMutex.withLock { + _activeProfileContext.value = ActiveProfileContext.Switching( + queries.selectPendingProfileContextRecovery() + .executeAsOneOrNull() + ?.prior_profile_id, + ) + try { + reconcileActiveProfileContextLocked(publishReady = true) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + throw ProfileContextRecoveryException(error) + } + } + } + + override suspend fun linkToSupabase(profileId: String, supabaseUserId: String) { + profileContextMutex.withLock { + queries.linkProfileToSupabase( + supabase_user_id = supabaseUserId, + last_auth_at = currentTimeMillis(), + id = profileId, + ) + refreshProfilesSync() + republishReadyIdentityIfActive(profileId) + } + } + + override suspend fun updateSubscriptionStatus( + profileId: String, + status: SubscriptionStatus, + expiresAt: Long?, + ) { + profileContextMutex.withLock { + queries.updateSubscriptionStatus( + subscription_status = status.toDbString(), + subscription_expires_at = expiresAt, + id = profileId, + ) + refreshProfilesSync() + republishReadyIdentityIfActive(profileId) + } + } + + override suspend fun getProfileBySupabaseId(supabaseUserId: String): UserProfile? = + queries.getProfileBySupabaseId(supabaseUserId) + .executeAsOneOrNull() + ?.toUserProfile() + + override fun getActiveProfileSubscriptionStatus(): Flow = flow { + val result = queries.getActiveProfileSubscriptionStatus().executeAsOneOrNull() + emit(SubscriptionStatus.fromString(result?.subscription_status)) + } + + private suspend fun withProfileContextTransition( + targetProfileId: String?, + operation: suspend (previous: ActiveProfileContext.Ready) -> T, + ): T { + val previous = _activeProfileContext.value as? ActiveProfileContext.Ready + ?: throw ProfileContextUnavailableException() + _activeProfileContext.value = ActiveProfileContext.Switching(targetProfileId) + return try { + operation(previous) + } catch (failure: Throwable) { + _activeProfileContext.value = ActiveProfileContext.Switching(previous.profile.id) + runCatching { + reconcileActiveProfileContextLocked(publishReady = true) + }.getOrElse { recoveryFailure -> + failure.addSuppressed(recoveryFailure) + throw ProfileContextRecoveryException(failure) + } + throw failure + } + } + + private suspend fun mutateActiveProfile( + expectedProfileId: String, + write: suspend (ActiveProfileContext.Ready) -> Unit, + ) { + profileContextMutex.withLock { + val context = _activeProfileContext.value as? ActiveProfileContext.Ready + ?: throw ProfileContextUnavailableException() + if (context.profile.id != expectedProfileId) { + throw StaleProfileContextException(expectedProfileId, context.profile.id) + } + write(context) + publishReadyContext(expectedProfileId) + } + } + + private suspend fun reconcileActiveProfileContextLocked(publishReady: Boolean) { + val pending = queries.selectPendingProfileContextRecovery().executeAsOneOrNull() + pending?.let { transition -> + database.transaction { + val priorId = queries.getProfileById(transition.prior_profile_id) + .executeAsOneOrNull() + ?.id + ?: DEFAULT_PROFILE_ID + queries.setActiveProfile(priorId) + transition.created_profile_id?.let { failedCreatedId -> + queries.deleteProfilePreferences(failedCreatedId) + queries.deleteProfile(failedCreatedId) + } + } + } refreshProfilesSync() + val actualActiveId = queries.getActiveProfile().executeAsOneOrNull()?.id + ?: error("No active profile after context reconciliation") + if (publishReady) { + publishReadyContext(actualActiveId) + } else { + _activeProfileContext.value = ActiveProfileContext.Switching(actualActiveId) + } + if (pending != null) { + try { + queries.clearPendingProfileContextRecovery() + } catch (failure: Throwable) { + _activeProfileContext.value = ActiveProfileContext.Switching(actualActiveId) + throw failure + } + } + } + + private suspend fun publishReadyContext(profileId: String) { + val profile = allProfiles.value.firstOrNull { it.id == profileId } + ?: error("Active profile missing from identity flow: $profileId") + val preferences = profilePreferencesRepository.get(profileId) + val localSafety = profileLocalSafetyStore.read(profileId) + _activeProfileContext.value = ActiveProfileContext.Ready( + profile = profile, + preferences = preferences, + localSafety = localSafety, + ) } - private fun com.devil.phoenixproject.database.UserProfile.toUserProfile(): UserProfile = UserProfile( + private suspend fun republishReadyIdentityIfActive(profileId: String) { + val ready = _activeProfileContext.value as? ActiveProfileContext.Ready + if (ready?.profile?.id == profileId) publishReadyContext(profileId) + } + + private fun ensureDefaultProfileSync() { + if (queries.countProfiles().executeAsOne() == 0L) { + queries.insertProfile( + id = DEFAULT_PROFILE_ID, + name = "Default", + colorIndex = 0L, + createdAt = currentTimeMillis(), + isActive = 1L, + ) + } + refreshProfilesSync() + if (activeProfile.value == null && allProfiles.value.isNotEmpty()) { + val targetId = allProfiles.value.firstOrNull { it.id == DEFAULT_PROFILE_ID }?.id + ?: allProfiles.value.first().id + queries.setActiveProfile(targetId) + refreshProfilesSync() + } + } + + private fun refreshProfilesSync() { + val profiles = queries.getAllProfiles().executeAsList().map { it.toUserProfile() } + _allProfiles.value = profiles + _activeProfile.value = profiles.find { it.isActive } + } + + private fun com.devil.phoenixproject.database.UserProfile.toUserProfile() = UserProfile( id = id, name = name, colorIndex = colorIndex.toInt(), @@ -191,32 +597,7 @@ class SqlDelightUserProfileRepository(private val database: VitruvianDatabase) : lastAuthAt = last_auth_at, ) - // Subscription methods implementation - override suspend fun linkToSupabase(profileId: String, supabaseUserId: String) { - queries.linkProfileToSupabase( - supabase_user_id = supabaseUserId, - last_auth_at = currentTimeMillis(), - id = profileId, - ) - refreshProfilesSync() - } - - override suspend fun updateSubscriptionStatus(profileId: String, status: SubscriptionStatus, expiresAt: Long?) { - queries.updateSubscriptionStatus( - subscription_status = status.toDbString(), - subscription_expires_at = expiresAt, - id = profileId, - ) - refreshProfilesSync() - } - - override suspend fun getProfileBySupabaseId(supabaseUserId: String): UserProfile? = queries.getProfileBySupabaseId(supabaseUserId) - .executeAsOneOrNull() - ?.toUserProfile() - - override fun getActiveProfileSubscriptionStatus(): Flow = kotlinx.coroutines.flow.flow { - val result = queries.getActiveProfileSubscriptionStatus() - .executeAsOneOrNull() - emit(SubscriptionStatus.fromString(result?.subscription_status)) + private companion object { + const val DEFAULT_PROFILE_ID = "default" } } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/WorkoutRepository.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/WorkoutRepository.kt index e64bdf44f..8b59fdd38 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/WorkoutRepository.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/WorkoutRepository.kt @@ -5,6 +5,8 @@ import com.devil.phoenixproject.domain.model.WorkoutSession import com.devil.phoenixproject.domain.onerepmax.WorkoutVelocityPoint import kotlinx.coroutines.flow.Flow +const val MAX_RECENT_EXERCISE_SESSIONS = 5 + /** * Personal record entity. */ @@ -63,6 +65,14 @@ interface WorkoutRepository { */ fun getHistoryVisibleSessions(profileId: String): Flow> + suspend fun getRecentCompletedSessionsForExercise( + exerciseId: String, + profileId: String, + limit: Int = MAX_RECENT_EXERCISE_SESSIONS, + ): List + + suspend fun getMostRecentCompletedExerciseId(profileId: String): String? + /** * Get a specific workout session by ID */ diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/PortalApiClient.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/PortalApiClient.kt index 48b7ea291..89ac03808 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/PortalApiClient.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/PortalApiClient.kt @@ -2,7 +2,9 @@ package com.devil.phoenixproject.data.sync import co.touchlab.kermit.Logger import io.ktor.client.HttpClient +import io.ktor.client.HttpClientConfig import io.ktor.client.call.body +import io.ktor.client.engine.HttpClientEngine import io.ktor.client.network.sockets.ConnectTimeoutException import io.ktor.client.network.sockets.SocketTimeoutException import io.ktor.client.plugins.HttpRequestTimeoutException @@ -24,7 +26,6 @@ import io.ktor.serialization.kotlinx.json.json import kotlinx.coroutines.CancellationException import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import kotlinx.serialization.json.Json /** * Subscription tier precedence (high → low). The portal may return multiple @@ -48,6 +49,29 @@ internal fun highestKnownTier(subscriptions: List): String .maxByOrNull { (_, rank) -> rank } ?.first +private fun HttpClientConfig<*>.configurePortalHttpClient() { + install(ContentNegotiation) { + json(PortalWireJson) + } + install(HttpTimeout) { + // Issue 4.4: Increased from 30s to 60s for large payloads on slow connections. + // Batch size of 50 sessions with nested telemetry can be several MB. + requestTimeoutMillis = 60_000 + connectTimeoutMillis = 10_000 + socketTimeoutMillis = 60_000 + } + defaultRequest { + contentType(ContentType.Application.Json) + } +} + +private fun createPortalHttpClient(engine: HttpClientEngine?): HttpClient = + if (engine == null) { + HttpClient { configurePortalHttpClient() } + } else { + HttpClient(engine) { configurePortalHttpClient() } + } + /** * Categorizes sync errors for appropriate retry handling. */ @@ -164,7 +188,7 @@ fun classifyByStatusCode( ) // Bad request, not found - permanent errors, don't retry - 400, 404 -> ClassifiedSyncError( + 400, 404, 413 -> ClassifiedSyncError( category = SyncErrorCategory.PERMANENT, message = message, statusCode = statusCode, @@ -200,32 +224,14 @@ fun classifyByStatusCode( ) } -open class PortalApiClient(private val supabaseConfig: SupabaseConfig, private val tokenStorage: PortalTokenStorage) { +open class PortalApiClient( + private val supabaseConfig: SupabaseConfig, + private val tokenStorage: PortalTokenStorage, + httpClientEngine: HttpClientEngine? = null, +) { private val refreshMutex = Mutex() - - private val json = Json { - ignoreUnknownKeys = true - isLenient = true - encodeDefaults = true - explicitNulls = false - } - - private val httpClient = HttpClient { - install(ContentNegotiation) { - json(json) - } - install(HttpTimeout) { - // Issue 4.4: Increased from 30s to 60s for large payloads on slow connections. - // Batch size of 50 sessions with nested telemetry can be several MB. - requestTimeoutMillis = 60_000 - connectTimeoutMillis = 10_000 - socketTimeoutMillis = 60_000 // Socket read timeout - must be set explicitly - } - defaultRequest { - contentType(ContentType.Application.Json) - } - } + private val httpClient = createPortalHttpClient(httpClientEngine) // === GoTrue Auth Endpoints === @@ -475,15 +481,33 @@ open class PortalApiClient(private val supabaseConfig: SupabaseConfig, private v ) } - // Serialize once to measure size. Cache the byte array so we do not - // pay the encoding cost twice (once for the size check, once for the error message). - val serialized = json.encodeToString(PortalSyncPayload.serializer(), payload) - val payloadBytes = serialized.encodeToByteArray() - if (payloadBytes.size > SyncConfig.MAX_PAYLOAD_BYTES) { + val encoded = encodePortalSyncPayload(payload) + if (payload.profilePreferenceSections != null) { + encoded.preferenceElementSpans.forEach { span -> + if (encoded.preferenceElementByteCount(span) > MAX_PROFILE_PREFERENCE_SECTION_BYTES) { + return Result.failure( + PortalApiException( + "SECTION_TOO_LARGE: cap=$MAX_PROFILE_PREFERENCE_SECTION_BYTES", + statusCode = 413, + ), + ) + } + } + if (encoded.rawBytes.size > MAX_PROFILE_PREFERENCE_REQUEST_BYTES) { + return Result.failure( + PortalApiException( + "REQUEST_TOO_LARGE: cap=$MAX_PROFILE_PREFERENCE_REQUEST_BYTES", + statusCode = 413, + ), + ) + } + } + if (encoded.rawBytes.size > SyncConfig.MAX_PAYLOAD_BYTES) { return Result.failure( PortalApiException( - "Push payload is ${payloadBytes.size} bytes; " + + "Push payload is ${encoded.rawBytes.size} bytes; " + "cap is ${SyncConfig.MAX_PAYLOAD_BYTES} bytes. Caller must split.", + statusCode = 413, ), ) } @@ -492,8 +516,8 @@ open class PortalApiClient(private val supabaseConfig: SupabaseConfig, private v httpClient.post("${supabaseConfig.url}/functions/v1/mobile-sync-push") { bearerAuth(token) header("apikey", supabaseConfig.anonKey) - header("Content-Type", "application/json") - setBody(serialized) + contentType(ContentType.Application.Json) + setBody(encoded.rawBytes) } } } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/PortalPullAdapter.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/PortalPullAdapter.kt index 7824be811..963cdfd15 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/PortalPullAdapter.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/PortalPullAdapter.kt @@ -1,8 +1,10 @@ package com.devil.phoenixproject.data.sync import com.devil.phoenixproject.domain.model.ProgramMode +import com.devil.phoenixproject.domain.model.ProfilePreferenceSectionName import com.devil.phoenixproject.domain.model.WorkoutSession import com.devil.phoenixproject.domain.model.currentTimeMillis +import kotlinx.serialization.json.JsonPrimitive /** * Converts portal pull response DTOs (camelCase) to domain objects and legacy merge DTOs @@ -19,6 +21,47 @@ import com.devil.phoenixproject.domain.model.currentTimeMillis */ object PortalPullAdapter { + fun toCanonicalProfilePreferenceSection( + dto: PortalProfilePreferenceSectionCanonicalDto, + ): ProfilePreferenceCanonicalDecodeResult { + fun invalid(reason: ProfilePreferenceSyncIssueReason) = + ProfilePreferenceCanonicalDecodeResult.Invalid( + localProfileId = dto.localProfileId, + section = dto.section, + reason = reason.name, + ) + + if (dto.localProfileId.isBlank() || + profilePreferenceWireSafetyViolation(JsonPrimitive(dto.localProfileId)) != null + ) { + return invalid(ProfilePreferenceSyncIssueReason.INVALID_PROFILE_ID) + } + val section = ProfilePreferenceSectionName.entries.firstOrNull { it.name == dto.section } + ?: return invalid(ProfilePreferenceSyncIssueReason.UNSUPPORTED_SECTION) + val updatedAt = ProfilePreferenceSyncCodec.parseStrictRfc3339EpochMilliseconds( + dto.serverUpdatedAt, + ) + if (updatedAt == null || + updatedAt !in ProfilePreferenceSyncCodec.MIN_RFC3339_EPOCH_MILLIS.. + ProfilePreferenceSyncCodec.MAX_RFC3339_EPOCH_MILLIS + ) { + return invalid(ProfilePreferenceSyncIssueReason.INVALID_CANONICAL_TIMESTAMP) + } + + val candidate = CanonicalProfilePreferenceSection( + key = ProfilePreferenceSectionKey(dto.localProfileId, section), + documentVersion = dto.documentVersion, + serverRevision = dto.serverRevision, + serverUpdatedAtEpochMs = updatedAt, + payload = dto.payload, + ) + return when (val decoded = ProfilePreferenceSyncCodec().decodeCanonical(candidate)) { + is ProfilePreferenceCanonicalColumnsResult.Invalid -> invalid(decoded.reason) + is ProfilePreferenceCanonicalColumnsResult.Valid -> + ProfilePreferenceCanonicalDecodeResult.Valid(candidate) + } + } + /** * Convert a portal workout session (1 workout with N exercises) to N mobile * WorkoutSession rows (1 per exercise). This is the reverse of the push diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/PortalSyncAdapter.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/PortalSyncAdapter.kt index d7120f18d..be22dc53f 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/PortalSyncAdapter.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/PortalSyncAdapter.kt @@ -35,6 +35,23 @@ import kotlinx.serialization.json.Json */ object PortalSyncAdapter { + fun toPortalProfilePreferenceMutation( + section: ProfilePreferenceSectionSyncDto, + ): PreparedProfilePreferenceMutation = PreparedProfilePreferenceMutation( + wire = PortalProfilePreferenceSectionMutationDto( + localProfileId = section.key.localProfileId, + section = section.key.section.name, + documentVersion = section.documentVersion, + baseRevision = section.baseRevision, + clientModifiedAt = kotlin.time.Instant + .fromEpochMilliseconds(section.clientModifiedAtEpochMs) + .toString(), + payload = section.payload, + ), + key = section.key, + sentLocalGeneration = section.localGeneration, + ) + // ─── Session Mapping ──────────────────────────────────────────── /** diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/PortalSyncDtos.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/PortalSyncDtos.kt index a57411f43..f0004f241 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/PortalSyncDtos.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/PortalSyncDtos.kt @@ -399,6 +399,116 @@ data class PortalAssessmentResultDto( @Serializable data class LocalProfileDto(val id: String, val name: String, val colorIndex: Int) +private val LOCAL_ONLY_PROFILE_PREFERENCE_KEYS = setOf( + "safeword", + "safewordcalibrated", + "adultsonlyconfirmed", + "adultsonlyprompted", + "localgeneration", + "dirty", + "legacymigrationversion", +) + +private fun normalizedProfilePreferenceWireKey(key: String): String = + key + .filter { it in 'a'..'z' || it in 'A'..'Z' || it in '0'..'9' } + .lowercase() + +internal enum class ProfilePreferenceWireSafetyViolation { + INVALID_TEXT_TREE, + LOCAL_ONLY_KEY, +} + +internal fun isPostgresCompatibleText(value: String): Boolean { + var index = 0 + while (index < value.length) { + val codeUnit = value[index] + when { + codeUnit == '\u0000' -> return false + codeUnit in '\uD800'..'\uDBFF' -> { + if (index + 1 >= value.length || value[index + 1] !in '\uDC00'..'\uDFFF') { + return false + } + index += 1 + } + codeUnit in '\uDC00'..'\uDFFF' -> return false + } + index += 1 + } + return true +} + +internal fun profilePreferenceWireSafetyViolation( + value: kotlinx.serialization.json.JsonElement, +): ProfilePreferenceWireSafetyViolation? = when (value) { + is kotlinx.serialization.json.JsonArray -> value.firstNotNullOfOrNull( + ::profilePreferenceWireSafetyViolation, + ) + is kotlinx.serialization.json.JsonObject -> { + value.entries.firstNotNullOfOrNull { (key, child) -> + when { + !isPostgresCompatibleText(key) -> + ProfilePreferenceWireSafetyViolation.INVALID_TEXT_TREE + normalizedProfilePreferenceWireKey(key) in LOCAL_ONLY_PROFILE_PREFERENCE_KEYS -> + ProfilePreferenceWireSafetyViolation.LOCAL_ONLY_KEY + else -> profilePreferenceWireSafetyViolation(child) + } + } + } + is kotlinx.serialization.json.JsonPrimitive -> if ( + value.isString && !isPostgresCompatibleText(value.content) + ) { + ProfilePreferenceWireSafetyViolation.INVALID_TEXT_TREE + } else { + null + } +} + +private fun requireValueOnlyProfilePreferencePayload( + value: kotlinx.serialization.json.JsonElement, +) { + require(profilePreferenceWireSafetyViolation(value) == null) { + "Profile preference payload is not wire-safe" + } +} + +@Serializable +data class PortalProfilePreferenceSectionMutationDto( + val localProfileId: String, + val section: String, + val documentVersion: Int, + val baseRevision: Long, + val clientModifiedAt: String, + val payload: kotlinx.serialization.json.JsonObject, +) { + init { + requireValueOnlyProfilePreferencePayload(payload) + } +} + +@Serializable +data class PortalProfilePreferenceSectionCanonicalDto( + val localProfileId: String, + val section: String, + val documentVersion: Int, + val serverRevision: Long, + val serverUpdatedAt: String, + val payload: kotlinx.serialization.json.JsonObject, +) { + init { + requireValueOnlyProfilePreferencePayload(payload) + } +} + +@Serializable +data class ProfilePreferenceSectionRejectionDto( + val localProfileId: String, + val section: String, + val serverRevision: Long, + val reason: String, + val canonicalSection: PortalProfilePreferenceSectionCanonicalDto? = null, +) + // ─── Push Response ────────────────────────────────────────────────── /** @@ -435,6 +545,9 @@ data class PortalSyncPushResponse( ) val externalActivityIds: List = emptyList(), val externalActivityKeys: List = emptyList(), + val profilePreferencesAccepted: Boolean? = null, + val canonicalProfilePreferenceSections: List = emptyList(), + val profilePreferenceRejections: List = emptyList(), /** * Per-entity LWW rejections. Empty when SYNC_LWW_ENABLED is false on * the server or when every incoming row cleared the LWW gate. Phase 3.2. @@ -554,6 +667,7 @@ data class PortalSyncPayload( val externalActivities: List = emptyList(), // Dedicated personal_records rows; authoritative over legacy set PR hints. val personalRecords: List = emptyList(), + val profilePreferenceSections: List? = null, ) // ─── External Activities (Integration sync) ────────────────────────── @@ -647,6 +761,7 @@ data class PortalSyncPullResponse( val gamificationStats: PullGamificationStatsDto? = null, // Profile data separation: profile list from portal (round-trip) val localProfiles: List? = null, + val profilePreferenceSections: List? = null, // External integration activities (paid users only) val externalActivities: List = emptyList(), ) diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/PortalWireJson.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/PortalWireJson.kt new file mode 100644 index 000000000..95028d54a --- /dev/null +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/PortalWireJson.kt @@ -0,0 +1,273 @@ +package com.devil.phoenixproject.data.sync + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +internal val PortalWireJson = Json { + ignoreUnknownKeys = true + isLenient = true + encodeDefaults = true + explicitNulls = false +} + +internal data class RawJsonSpan(val start: Int, val endExclusive: Int) + +internal data class EncodedPortalSyncPayload( + val raw: String, + val rawBytes: ByteArray, + val preferenceElementSpans: List, +) { + fun preferenceElementByteCount(span: RawJsonSpan): Int = + raw.substring(span.start, span.endExclusive).encodeToByteArray().size +} + +internal fun encodePortalSyncPayload(payload: PortalSyncPayload): EncodedPortalSyncPayload { + val raw = PortalWireJson.encodeToString(PortalSyncPayload.serializer(), payload) + val spans = scanProfilePreferenceElementSpans(raw) + check(spans.size == payload.profilePreferenceSections.orEmpty().size) { + "Serialized profile preference span count mismatch" + } + return EncodedPortalSyncPayload(raw, raw.encodeToByteArray(), spans) +} + +internal fun scanProfilePreferenceElementSpans(raw: String): List = + PortalSyncPayloadRawScanner(raw).scan() + +private class PortalSyncPayloadRawScanner( + private val raw: String, +) { + private var index = 0 + + fun scan(): List { + skipWhitespace() + if (!consume('{')) fail(INVALID_JSON_ROOT) + + val spans = mutableListOf() + var foundPreferenceSections = false + skipWhitespace() + if (!consume('}')) { + while (true) { + skipWhitespace() + if (peek() != '"') fail(INVALID_JSON_STRUCTURE) + val propertyName = parseString() + skipWhitespace() + if (!consume(':')) fail(INVALID_JSON_STRUCTURE) + skipWhitespace() + + if (propertyName == PROFILE_PREFERENCE_SECTIONS) { + if (foundPreferenceSections) { + fail(DUPLICATE_PROFILE_PREFERENCE_SECTIONS) + } + foundPreferenceSections = true + if (peek() != '[') fail(INVALID_PROFILE_PREFERENCE_ARRAY) + spans += parsePreferenceArray() + } else { + parseValue() + } + + skipWhitespace() + when { + consume(',') -> { + skipWhitespace() + if (peek() == '}') fail(INVALID_JSON_STRUCTURE) + } + consume('}') -> break + else -> fail(INVALID_JSON_STRUCTURE) + } + } + } + + skipWhitespace() + if (index != raw.length) fail(TRAILING_JSON_DATA) + return spans + } + + private fun parsePreferenceArray(): List { + if (!consume('[')) fail(INVALID_PROFILE_PREFERENCE_ARRAY) + val spans = mutableListOf() + skipWhitespace() + if (consume(']')) return spans + + while (true) { + skipWhitespace() + val start = index + parseValue() + spans += RawJsonSpan(start = start, endExclusive = index) + skipWhitespace() + when { + consume(',') -> { + skipWhitespace() + if (peek() == ']') fail(INVALID_JSON_STRUCTURE) + } + consume(']') -> return spans + else -> fail(INVALID_JSON_STRUCTURE) + } + } + } + + private fun parseValue() { + skipWhitespace() + when (peek()) { + '"' -> parseString() + '{' -> parseObject() + '[' -> parseArray() + 't' -> parseLiteral("true") + 'f' -> parseLiteral("false") + 'n' -> parseLiteral("null") + '-', in '0'..'9' -> parseNumber() + else -> fail(INVALID_JSON_STRUCTURE) + } + } + + private fun parseObject() { + if (!consume('{')) fail(INVALID_JSON_STRUCTURE) + skipWhitespace() + if (consume('}')) return + + while (true) { + skipWhitespace() + if (peek() != '"') fail(INVALID_JSON_STRUCTURE) + parseString() + skipWhitespace() + if (!consume(':')) fail(INVALID_JSON_STRUCTURE) + parseValue() + skipWhitespace() + when { + consume(',') -> { + skipWhitespace() + if (peek() == '}') fail(INVALID_JSON_STRUCTURE) + } + consume('}') -> return + else -> fail(INVALID_JSON_STRUCTURE) + } + } + } + + private fun parseArray() { + if (!consume('[')) fail(INVALID_JSON_STRUCTURE) + skipWhitespace() + if (consume(']')) return + + while (true) { + parseValue() + skipWhitespace() + when { + consume(',') -> { + skipWhitespace() + if (peek() == ']') fail(INVALID_JSON_STRUCTURE) + } + consume(']') -> return + else -> fail(INVALID_JSON_STRUCTURE) + } + } + } + + private fun parseString(): String { + if (!consume('"')) fail(INVALID_JSON_STRUCTURE) + val decoded = StringBuilder() + while (index < raw.length) { + val character = raw[index++] + when { + character == '"' -> return decoded.toString() + character == '\\' -> decoded.append(parseEscape()) + character.code < 0x20 -> fail(INVALID_JSON_STRUCTURE) + else -> decoded.append(character) + } + } + fail(INVALID_JSON_STRUCTURE) + } + + private fun parseEscape(): Char { + if (index >= raw.length) fail(INVALID_JSON_STRUCTURE) + return when (val escaped = raw[index++]) { + '"' -> '"' + '\\' -> '\\' + '/' -> '/' + 'b' -> '\b' + 'f' -> '\u000C' + 'n' -> '\n' + 'r' -> '\r' + 't' -> '\t' + 'u' -> parseUnicodeEscape() + else -> fail(INVALID_JSON_STRUCTURE) + } + } + + private fun parseUnicodeEscape(): Char { + if (index + 4 > raw.length) fail(INVALID_JSON_STRUCTURE) + var value = 0 + repeat(4) { + value = value * 16 + hexValue(raw[index++]) + } + return value.toChar() + } + + private fun hexValue(character: Char): Int = when (character) { + in '0'..'9' -> character - '0' + in 'a'..'f' -> character - 'a' + 10 + in 'A'..'F' -> character - 'A' + 10 + else -> fail(INVALID_JSON_STRUCTURE) + } + + private fun parseLiteral(literal: String) { + if (index + literal.length > raw.length || + raw.substring(index, index + literal.length) != literal + ) { + fail(INVALID_JSON_STRUCTURE) + } + index += literal.length + } + + private fun parseNumber() { + consume('-') + when (peek()) { + '0' -> { + index++ + if (peek() in '0'..'9') fail(INVALID_JSON_STRUCTURE) + } + in '1'..'9' -> { + index++ + while (peek() in '0'..'9') index++ + } + else -> fail(INVALID_JSON_STRUCTURE) + } + + if (consume('.')) { + if (peek() !in '0'..'9') fail(INVALID_JSON_STRUCTURE) + while (peek() in '0'..'9') index++ + } + if (peek() == 'e' || peek() == 'E') { + index++ + if (peek() == '+' || peek() == '-') index++ + if (peek() !in '0'..'9') fail(INVALID_JSON_STRUCTURE) + while (peek() in '0'..'9') index++ + } + } + + private fun skipWhitespace() { + while (peek() == ' ' || peek() == '\t' || peek() == '\r' || peek() == '\n') { + index++ + } + } + + private fun consume(character: Char): Boolean = if (peek() == character) { + index++ + true + } else { + false + } + + private fun peek(): Char? = raw.getOrNull(index) + + private fun fail(message: String): Nothing = throw IllegalArgumentException(message) + + private companion object { + const val PROFILE_PREFERENCE_SECTIONS = "profilePreferenceSections" + const val INVALID_JSON_ROOT = "INVALID_JSON_ROOT" + const val INVALID_JSON_STRUCTURE = "INVALID_JSON_STRUCTURE" + const val INVALID_PROFILE_PREFERENCE_ARRAY = "INVALID_PROFILE_PREFERENCE_ARRAY" + const val DUPLICATE_PROFILE_PREFERENCE_SECTIONS = + "DUPLICATE_PROFILE_PREFERENCE_SECTIONS" + const val TRAILING_JSON_DATA = "TRAILING_JSON_DATA" + } +} diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/ProfilePreferenceSyncCodec.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/ProfilePreferenceSyncCodec.kt new file mode 100644 index 000000000..2ef817246 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/ProfilePreferenceSyncCodec.kt @@ -0,0 +1,669 @@ +package com.devil.phoenixproject.data.sync + +import com.devil.phoenixproject.data.preferences.ProfilePreferencesCodec +import com.devil.phoenixproject.data.preferences.ProfilePreferencesValidator +import com.devil.phoenixproject.database.UserProfilePreferences as ProfilePreferenceRow +import com.devil.phoenixproject.domain.model.CoreProfilePreferences +import com.devil.phoenixproject.domain.model.LedPreferences +import com.devil.phoenixproject.domain.model.ProfilePreferenceSectionName +import com.devil.phoenixproject.domain.model.ProfilePreferenceValidity +import com.devil.phoenixproject.domain.model.RackPreferences +import com.devil.phoenixproject.domain.model.VbtPreferences +import com.devil.phoenixproject.domain.model.WeightUnit +import com.devil.phoenixproject.domain.model.WorkoutPreferences +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.float +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put + +internal class ProfilePreferenceSyncCodec { + companion object { + const val MAX_EXACT_JSON_INTEGER = 9_007_199_254_740_991L + const val MIN_EXACT_JSON_INTEGER = -9_007_199_254_740_991L + const val MIN_RFC3339_EPOCH_MILLIS = -62_135_596_800_000L + const val MAX_RFC3339_EPOCH_MILLIS = 253_402_300_799_999L + + private val RFC3339_INSTANT = Regex( + """([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})(?:\.([0-9]{1,9}))?(Z|([+-])([0-9]{2}):([0-9]{2}))""", + ) + + fun parseStrictRfc3339EpochMilliseconds(value: String): Long? { + val match = RFC3339_INSTANT.matchEntire(value) ?: return null + val year = match.groupValues[1].toInt() + val month = match.groupValues[2].toInt() + val day = match.groupValues[3].toInt() + val hour = match.groupValues[4].toInt() + val minute = match.groupValues[5].toInt() + val second = match.groupValues[6].toInt() + val fraction = match.groupValues[7] + val zone = match.groupValues[8] + val offsetSign = match.groupValues[9] + val offsetHour = if (zone == "Z") 0 else match.groupValues[10].toInt() + val offsetMinute = if (zone == "Z") 0 else match.groupValues[11].toInt() + if (year < 1 || + month !in 1..12 || + day !in 1..daysInMonth(year, month) || + hour > 23 || + minute > 59 || + second > 59 || + offsetHour > 23 || + offsetMinute > 59 + ) { + return null + } + val localInstant = buildString { + append(match.groupValues[1]) + append('-') + append(match.groupValues[2]) + append('-') + append(match.groupValues[3]) + append('T') + append(match.groupValues[4]) + append(':') + append(match.groupValues[5]) + append(':') + append(match.groupValues[6]) + if (fraction.isNotEmpty()) { + append('.') + append(fraction) + } + append('Z') + } + val localEpochMilliseconds = runCatching { + kotlin.time.Instant.parse(localInstant).toEpochMilliseconds() + }.getOrNull() ?: return null + val offsetMilliseconds = (offsetHour * 60L + offsetMinute) * 60_000L + return when (offsetSign) { + "+" -> localEpochMilliseconds - offsetMilliseconds + "-" -> localEpochMilliseconds + offsetMilliseconds + else -> localEpochMilliseconds + } + } + + private fun isLeapYear(year: Int): Boolean = + year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) + + private fun daysInMonth(year: Int, month: Int): Int = when (month) { + 1, 3, 5, 7, 8, 10, 12 -> 31 + 4, 6, 9, 11 -> 30 + 2 -> if (isLeapYear(year)) 29 else 28 + else -> 0 + } + } + + private data class LocalPayload( + val documentVersion: Int, + val payload: JsonObject, + ) + + private sealed interface LocalPayloadResult { + data class Valid(val value: LocalPayload) : LocalPayloadResult + data class Invalid(val reason: ProfilePreferenceSyncIssueReason) : LocalPayloadResult + } + + private fun exactJsonIntegerIssue(value: Long): ProfilePreferenceSyncIssueReason? = + if (value in MIN_EXACT_JSON_INTEGER..MAX_EXACT_JSON_INTEGER) { + null + } else { + ProfilePreferenceSyncIssueReason.UNREPRESENTABLE_JSON_INTEGER + } + + private fun document(encoded: String): JsonObject = + PortalWireJson.parseToJsonElement(encoded).jsonObject + + fun ledPayload(value: LedPreferences): JsonObject = buildJsonObject { + put("ledColorSchemeId", value.colorScheme) + put("preferences", document(ProfilePreferencesCodec.encodeLed(value))) + } + + fun vbtPayload(value: VbtPreferences): JsonObject = buildJsonObject { + put("vbtEnabled", value.enabled) + put("preferences", document(ProfilePreferencesCodec.encodeVbt(value))) + } + + fun corePayload(value: CoreProfilePreferences): JsonObject = buildJsonObject { + put("bodyWeightKg", value.bodyWeightKg) + put("weightUnit", value.weightUnit.name) + put("weightIncrement", value.weightIncrement) + } + + fun rackPayload(value: RackPreferences): JsonObject = + document(ProfilePreferencesCodec.encodeRack(value)) + + fun workoutPayload(value: WorkoutPreferences): JsonObject = + document(ProfilePreferencesCodec.encodeWorkout(value)) + + fun normalizedPayload(value: DecodedProfilePreferenceValue): JsonObject = when (value) { + is DecodedProfilePreferenceValue.Core -> corePayload(value.value) + is DecodedProfilePreferenceValue.Rack -> rackPayload(value.value) + is DecodedProfilePreferenceValue.Workout -> workoutPayload(value.value) + is DecodedProfilePreferenceValue.Led -> ledPayload(value.value) + is DecodedProfilePreferenceValue.Vbt -> vbtPayload(value.value) + } + + fun validateCanonicalPayload( + section: ProfilePreferenceSectionName, + documentVersion: Int, + payload: JsonObject, + ): ProfilePreferencePayloadValidation { + val reason = canonicalPayloadIssue(section, documentVersion, payload) + return ProfilePreferencePayloadValidation( + isValid = reason == null, + reason = reason?.name.orEmpty(), + ) + } + + fun encodeDirtyRow(row: ProfilePreferenceRow): EncodedDirtyProfilePreferenceRow { + val dirtySections = ProfilePreferenceSectionName.entries.filter { section -> + sectionDirty(row, section) + } + if (row.profile_id.isBlank() || !isPostgresCompatibleText(row.profile_id)) { + return EncodedDirtyProfilePreferenceRow( + valid = emptyList(), + unsyncable = dirtySections.map { section -> + issue( + row = row, + section = section, + reason = ProfilePreferenceSyncIssueReason.INVALID_PROFILE_ID, + ) + }, + ) + } + + val valid = mutableListOf() + val unsyncable = mutableListOf() + dirtySections.forEach { section -> + val baseRevision = sectionServerRevision(row, section) + val reason = when { + baseRevision < 0 -> ProfilePreferenceSyncIssueReason.INVALID_LOCAL_DOCUMENT + exactJsonIntegerIssue(baseRevision) != null -> exactJsonIntegerIssue(baseRevision) + sectionUpdatedAt(row, section) !in + MIN_RFC3339_EPOCH_MILLIS..MAX_RFC3339_EPOCH_MILLIS -> + ProfilePreferenceSyncIssueReason.INVALID_CLIENT_MODIFIED_AT + else -> null + } + if (reason != null) { + unsyncable += issue(row, section, reason) + return@forEach + } + + when (val localPayload = localPayload(row, section)) { + is LocalPayloadResult.Invalid -> + unsyncable += issue(row, section, localPayload.reason) + is LocalPayloadResult.Valid -> { + val payload = localPayload.value.payload + val wireIssue = when (profilePreferenceWireSafetyViolation(payload)) { + ProfilePreferenceWireSafetyViolation.INVALID_TEXT_TREE -> + ProfilePreferenceSyncIssueReason.INVALID_TEXT_TREE + ProfilePreferenceWireSafetyViolation.LOCAL_ONLY_KEY -> + ProfilePreferenceSyncIssueReason.LOCAL_ONLY_WIRE_KEY + null -> null + } + if (wireIssue != null) { + unsyncable += issue(row, section, wireIssue) + } else { + valid += ProfilePreferenceSectionSyncDto( + key = ProfilePreferenceSectionKey(row.profile_id, section), + documentVersion = localPayload.value.documentVersion, + baseRevision = baseRevision, + clientModifiedAtEpochMs = sectionUpdatedAt(row, section), + localGeneration = sectionLocalGeneration(row, section), + payload = payload, + ) + } + } + } + } + return EncodedDirtyProfilePreferenceRow(valid, unsyncable) + } + + private fun localPayload( + row: ProfilePreferenceRow, + section: ProfilePreferenceSectionName, + ): LocalPayloadResult = runCatching { + when (section) { + ProfilePreferenceSectionName.CORE -> { + val value = CoreProfilePreferences( + bodyWeightKg = row.body_weight_kg.toFloat(), + weightUnit = WeightUnit.valueOf(row.weight_unit), + weightIncrement = row.weight_increment.toFloat(), + ) + if (ProfilePreferencesValidator.core(value).isNotEmpty()) { + LocalPayloadResult.Invalid(ProfilePreferenceSyncIssueReason.INVALID_LOCAL_DOCUMENT) + } else { + LocalPayloadResult.Valid(LocalPayload(1, corePayload(value))) + } + } + ProfilePreferenceSectionName.RACK -> { + val decoded = ProfilePreferencesCodec.decodeRack(row.equipment_rack_json) + if (decoded.validity !is ProfilePreferenceValidity.Valid) { + LocalPayloadResult.Invalid(ProfilePreferenceSyncIssueReason.INVALID_LOCAL_DOCUMENT) + } else if (decoded.value.items.any { item -> + exactJsonIntegerIssue(item.createdAt) != null || + exactJsonIntegerIssue(item.updatedAt) != null + } + ) { + LocalPayloadResult.Invalid( + ProfilePreferenceSyncIssueReason.UNREPRESENTABLE_JSON_INTEGER, + ) + } else { + LocalPayloadResult.Valid( + LocalPayload(decoded.value.version, rackPayload(decoded.value)), + ) + } + } + ProfilePreferenceSectionName.WORKOUT -> { + val decoded = ProfilePreferencesCodec.decodeWorkout(row.workout_preferences_json) + if (decoded.validity !is ProfilePreferenceValidity.Valid) { + LocalPayloadResult.Invalid(ProfilePreferenceSyncIssueReason.INVALID_LOCAL_DOCUMENT) + } else { + LocalPayloadResult.Valid( + LocalPayload(decoded.value.version, workoutPayload(decoded.value)), + ) + } + } + ProfilePreferenceSectionName.LED -> { + if (row.led_color_scheme_id !in + Int.MIN_VALUE.toLong()..Int.MAX_VALUE.toLong() + ) { + LocalPayloadResult.Invalid(ProfilePreferenceSyncIssueReason.INVALID_INT32) + } else { + val decoded = ProfilePreferencesCodec.decodeLed( + row.led_preferences_json, + row.led_color_scheme_id.toInt(), + ) + if (decoded.validity !is ProfilePreferenceValidity.Valid) { + LocalPayloadResult.Invalid( + ProfilePreferenceSyncIssueReason.INVALID_LOCAL_DOCUMENT, + ) + } else { + LocalPayloadResult.Valid( + LocalPayload(decoded.value.version, ledPayload(decoded.value)), + ) + } + } + } + ProfilePreferenceSectionName.VBT -> { + val decoded = ProfilePreferencesCodec.decodeVbt( + row.vbt_preferences_json, + row.vbt_enabled == 1L, + ) + if (decoded.validity !is ProfilePreferenceValidity.Valid) { + LocalPayloadResult.Invalid(ProfilePreferenceSyncIssueReason.INVALID_LOCAL_DOCUMENT) + } else { + LocalPayloadResult.Valid( + LocalPayload(decoded.value.version, vbtPayload(decoded.value)), + ) + } + } + } + }.getOrElse { + LocalPayloadResult.Invalid(ProfilePreferenceSyncIssueReason.INVALID_LOCAL_DOCUMENT) + } + + private fun issue( + row: ProfilePreferenceRow, + section: ProfilePreferenceSectionName, + reason: ProfilePreferenceSyncIssueReason, + ) = ProfilePreferenceSyncIssue( + key = ProfilePreferenceSectionKey(row.profile_id, section), + localGeneration = sectionLocalGeneration(row, section), + reason = reason.name, + ) + + private fun sectionDirty( + row: ProfilePreferenceRow, + section: ProfilePreferenceSectionName, + ): Boolean = when (section) { + ProfilePreferenceSectionName.CORE -> row.core_dirty == 1L + ProfilePreferenceSectionName.RACK -> row.rack_dirty == 1L + ProfilePreferenceSectionName.WORKOUT -> row.workout_dirty == 1L + ProfilePreferenceSectionName.LED -> row.led_dirty == 1L + ProfilePreferenceSectionName.VBT -> row.vbt_dirty == 1L + } + + private fun sectionUpdatedAt( + row: ProfilePreferenceRow, + section: ProfilePreferenceSectionName, + ): Long = when (section) { + ProfilePreferenceSectionName.CORE -> row.core_updated_at + ProfilePreferenceSectionName.RACK -> row.rack_updated_at + ProfilePreferenceSectionName.WORKOUT -> row.workout_updated_at + ProfilePreferenceSectionName.LED -> row.led_updated_at + ProfilePreferenceSectionName.VBT -> row.vbt_updated_at + } + + private fun sectionLocalGeneration( + row: ProfilePreferenceRow, + section: ProfilePreferenceSectionName, + ): Long = when (section) { + ProfilePreferenceSectionName.CORE -> row.core_local_generation + ProfilePreferenceSectionName.RACK -> row.rack_local_generation + ProfilePreferenceSectionName.WORKOUT -> row.workout_local_generation + ProfilePreferenceSectionName.LED -> row.led_local_generation + ProfilePreferenceSectionName.VBT -> row.vbt_local_generation + } + + private fun sectionServerRevision( + row: ProfilePreferenceRow, + section: ProfilePreferenceSectionName, + ): Long = when (section) { + ProfilePreferenceSectionName.CORE -> row.core_server_revision + ProfilePreferenceSectionName.RACK -> row.rack_server_revision + ProfilePreferenceSectionName.WORKOUT -> row.workout_server_revision + ProfilePreferenceSectionName.LED -> row.led_server_revision + ProfilePreferenceSectionName.VBT -> row.vbt_server_revision + } + + private fun numericPrimitive(payload: JsonObject, key: String): JsonPrimitive = + payload.getValue(key).jsonPrimitive.also { require(!it.isString) } + + private fun stringPrimitive(payload: JsonObject, key: String): JsonPrimitive = + payload.getValue(key).jsonPrimitive.also { require(it.isString) } + + private fun booleanPrimitive(payload: JsonObject, key: String): JsonPrimitive = + numericPrimitive(payload, key).also { + require(it.content == "true" || it.content == "false") + } + + private fun decodeAndValidateTypedValue( + section: ProfilePreferenceSectionName, + payload: JsonObject, + ): DecodedProfilePreferenceValue { + val decoded = when (section) { + ProfilePreferenceSectionName.CORE -> DecodedProfilePreferenceValue.Core( + CoreProfilePreferences( + bodyWeightKg = numericPrimitive(payload, "bodyWeightKg").float, + weightUnit = WeightUnit.valueOf(stringPrimitive(payload, "weightUnit").content), + weightIncrement = numericPrimitive(payload, "weightIncrement").float, + ), + ) + ProfilePreferenceSectionName.RACK -> DecodedProfilePreferenceValue.Rack( + PortalWireJson.decodeFromJsonElement(payload), + ) + ProfilePreferenceSectionName.WORKOUT -> DecodedProfilePreferenceValue.Workout( + PortalWireJson.decodeFromJsonElement(payload), + ) + ProfilePreferenceSectionName.LED -> DecodedProfilePreferenceValue.Led( + PortalWireJson.decodeFromJsonElement( + payload.getValue("preferences").jsonObject, + ).copy( + colorScheme = numericPrimitive(payload, "ledColorSchemeId").int, + ), + ) + ProfilePreferenceSectionName.VBT -> DecodedProfilePreferenceValue.Vbt( + PortalWireJson.decodeFromJsonElement( + payload.getValue("preferences").jsonObject, + ).copy( + enabled = booleanPrimitive(payload, "vbtEnabled").boolean, + ), + ) + } + val errors = when (decoded) { + is DecodedProfilePreferenceValue.Core -> ProfilePreferencesValidator.core(decoded.value) + is DecodedProfilePreferenceValue.Rack -> ProfilePreferencesValidator.rack(decoded.value) + is DecodedProfilePreferenceValue.Workout -> ProfilePreferencesValidator.workout(decoded.value) + is DecodedProfilePreferenceValue.Led -> ProfilePreferencesValidator.led(decoded.value) + is DecodedProfilePreferenceValue.Vbt -> ProfilePreferencesValidator.vbt(decoded.value) + } + require(errors.isEmpty()) + return decoded + } + + private fun canonicalPayloadIssue( + section: ProfilePreferenceSectionName, + documentVersion: Int, + payload: JsonObject, + ): ProfilePreferenceSyncIssueReason? { + if (documentVersion != 1) { + return ProfilePreferenceSyncIssueReason.UNSUPPORTED_DOCUMENT_VERSION + } + when (profilePreferenceWireSafetyViolation(payload)) { + ProfilePreferenceWireSafetyViolation.INVALID_TEXT_TREE -> + return ProfilePreferenceSyncIssueReason.INVALID_TEXT_TREE + ProfilePreferenceWireSafetyViolation.LOCAL_ONLY_KEY -> + return ProfilePreferenceSyncIssueReason.LOCAL_ONLY_WIRE_KEY + null -> Unit + } + val explicitEmbeddedVersion = runCatching { + val element = when (section) { + ProfilePreferenceSectionName.CORE -> null + ProfilePreferenceSectionName.RACK, + ProfilePreferenceSectionName.WORKOUT -> payload["version"] + ProfilePreferenceSectionName.LED, + ProfilePreferenceSectionName.VBT -> + payload.getValue("preferences").jsonObject["version"] + } + element?.jsonPrimitive?.also { require(!it.isString) }?.int + }.getOrElse { + return ProfilePreferenceSyncIssueReason.INVALID_CANONICAL_PAYLOAD + } + if (explicitEmbeddedVersion != null && explicitEmbeddedVersion != documentVersion) { + return ProfilePreferenceSyncIssueReason.UNSUPPORTED_DOCUMENT_VERSION + } + val decoded = runCatching { + decodeAndValidateTypedValue(section, payload) + }.getOrElse { + return ProfilePreferenceSyncIssueReason.INVALID_CANONICAL_PAYLOAD + } + if (decoded is DecodedProfilePreferenceValue.Rack && + decoded.value.items.any { item -> + exactJsonIntegerIssue(item.createdAt) != null || + exactJsonIntegerIssue(item.updatedAt) != null + } + ) { + return ProfilePreferenceSyncIssueReason.UNREPRESENTABLE_JSON_INTEGER + } + val decodedVersion = when (decoded) { + is DecodedProfilePreferenceValue.Core -> 1 + is DecodedProfilePreferenceValue.Rack -> decoded.value.version + is DecodedProfilePreferenceValue.Workout -> decoded.value.version + is DecodedProfilePreferenceValue.Led -> decoded.value.version + is DecodedProfilePreferenceValue.Vbt -> decoded.value.version + } + return if (decodedVersion == documentVersion) { + null + } else { + ProfilePreferenceSyncIssueReason.UNSUPPORTED_DOCUMENT_VERSION + } + } + + fun decodeCanonical( + canonical: CanonicalProfilePreferenceSection, + ): ProfilePreferenceCanonicalColumnsResult { + if (canonical.key.localProfileId.isBlank() || + !isPostgresCompatibleText(canonical.key.localProfileId) + ) { + return ProfilePreferenceCanonicalColumnsResult.Invalid( + ProfilePreferenceSyncIssueReason.INVALID_PROFILE_ID, + ) + } + if (canonical.serverUpdatedAtEpochMs !in + MIN_RFC3339_EPOCH_MILLIS..MAX_RFC3339_EPOCH_MILLIS + ) { + return ProfilePreferenceCanonicalColumnsResult.Invalid( + ProfilePreferenceSyncIssueReason.INVALID_CANONICAL_TIMESTAMP, + ) + } + if (canonical.serverRevision !in 0..MAX_EXACT_JSON_INTEGER) { + return ProfilePreferenceCanonicalColumnsResult.Invalid( + ProfilePreferenceSyncIssueReason.INVALID_CANONICAL_REVISION, + ) + } + canonicalPayloadIssue( + canonical.key.section, + canonical.documentVersion, + canonical.payload, + )?.let { reason -> + return ProfilePreferenceCanonicalColumnsResult.Invalid(reason) + } + val value = runCatching { + decodeAndValidateTypedValue(canonical.key.section, canonical.payload) + }.getOrElse { + return ProfilePreferenceCanonicalColumnsResult.Invalid( + ProfilePreferenceSyncIssueReason.INVALID_CANONICAL_PAYLOAD, + ) + } + return ProfilePreferenceCanonicalColumnsResult.Valid( + DecodedProfilePreferenceColumns( + key = canonical.key, + documentVersion = canonical.documentVersion, + serverRevision = canonical.serverRevision, + serverUpdatedAtEpochMs = canonical.serverUpdatedAtEpochMs, + value = value, + normalizedPayload = normalizedPayload(value), + ), + ) + } + + fun currentState( + row: ProfilePreferenceRow, + section: ProfilePreferenceSectionName, + ): CurrentProfilePreferenceSyncState = when (section) { + ProfilePreferenceSectionName.CORE -> CurrentProfilePreferenceSyncState( + row.core_server_revision, + row.core_dirty == 1L, + runCatching { + CoreProfilePreferences( + row.body_weight_kg.toFloat(), + WeightUnit.valueOf(row.weight_unit), + row.weight_increment.toFloat(), + ).takeIf { ProfilePreferencesValidator.core(it).isEmpty() } + ?.let(::corePayload) + }.getOrNull(), + ) + ProfilePreferenceSectionName.RACK -> CurrentProfilePreferenceSyncState( + row.rack_server_revision, + row.rack_dirty == 1L, + runCatching { + ProfilePreferencesCodec.decodeRack(row.equipment_rack_json) + .takeIf { it.validity is ProfilePreferenceValidity.Valid } + ?.value + ?.let(::rackPayload) + }.getOrNull(), + ) + ProfilePreferenceSectionName.WORKOUT -> CurrentProfilePreferenceSyncState( + row.workout_server_revision, + row.workout_dirty == 1L, + runCatching { + ProfilePreferencesCodec.decodeWorkout(row.workout_preferences_json) + .takeIf { it.validity is ProfilePreferenceValidity.Valid } + ?.value + ?.let(::workoutPayload) + }.getOrNull(), + ) + ProfilePreferenceSectionName.LED -> CurrentProfilePreferenceSyncState( + row.led_server_revision, + row.led_dirty == 1L, + runCatching { + row.led_color_scheme_id + .takeIf { it in Int.MIN_VALUE.toLong()..Int.MAX_VALUE.toLong() } + ?.toInt() + ?.let { ProfilePreferencesCodec.decodeLed(row.led_preferences_json, it) } + ?.takeIf { it.validity is ProfilePreferenceValidity.Valid } + ?.value + ?.let(::ledPayload) + }.getOrNull(), + ) + ProfilePreferenceSectionName.VBT -> CurrentProfilePreferenceSyncState( + row.vbt_server_revision, + row.vbt_dirty == 1L, + runCatching { + ProfilePreferencesCodec.decodeVbt( + row.vbt_preferences_json, + row.vbt_enabled == 1L, + ).takeIf { it.validity is ProfilePreferenceValidity.Valid } + ?.value + ?.let(::vbtPayload) + }.getOrNull(), + ) + } + + fun hasCanonicalDivergence( + row: ProfilePreferenceRow, + columns: DecodedProfilePreferenceColumns, + ): Boolean { + val current = currentState(row, columns.section) + return !current.dirty && + current.serverRevision == columns.serverRevision && + current.normalizedPayload != columns.normalizedPayload + } + + fun hasCanonicalDivergence( + row: ProfilePreferenceRow, + canonical: CanonicalProfilePreferenceSection, + ): Boolean = when (val decoded = decodeCanonical(canonical)) { + is ProfilePreferenceCanonicalColumnsResult.Invalid -> false + is ProfilePreferenceCanonicalColumnsResult.Valid -> + hasCanonicalDivergence(row, decoded.columns) + } +} + +internal data class ProfilePreferencePayloadValidation( + val isValid: Boolean, + val reason: String, +) + +internal enum class ProfilePreferenceSyncIssueReason { + INVALID_PROFILE_ID, + INVALID_LOCAL_DOCUMENT, + INVALID_CLIENT_MODIFIED_AT, + UNREPRESENTABLE_JSON_INTEGER, + INVALID_INT32, + INVALID_TEXT_TREE, + LOCAL_ONLY_WIRE_KEY, + UNSUPPORTED_SECTION, + UNSUPPORTED_DOCUMENT_VERSION, + INVALID_CANONICAL_PAYLOAD, + INVALID_CANONICAL_REVISION, + INVALID_CANONICAL_TIMESTAMP, + SECTION_TOO_LARGE, + REQUEST_TOO_LARGE, + DUPLICATE_SECTION, +} + +internal sealed interface DecodedProfilePreferenceValue { + data class Core(val value: CoreProfilePreferences) : DecodedProfilePreferenceValue + data class Rack(val value: RackPreferences) : DecodedProfilePreferenceValue + data class Workout(val value: WorkoutPreferences) : DecodedProfilePreferenceValue + data class Led(val value: LedPreferences) : DecodedProfilePreferenceValue + data class Vbt(val value: VbtPreferences) : DecodedProfilePreferenceValue +} + +internal data class DecodedProfilePreferenceColumns( + val key: ProfilePreferenceSectionKey, + val documentVersion: Int, + val serverRevision: Long, + val serverUpdatedAtEpochMs: Long, + val value: DecodedProfilePreferenceValue, + val normalizedPayload: JsonObject, +) { + val section: ProfilePreferenceSectionName get() = key.section +} + +internal sealed interface ProfilePreferenceCanonicalColumnsResult { + data class Valid( + val columns: DecodedProfilePreferenceColumns, + ) : ProfilePreferenceCanonicalColumnsResult + + data class Invalid( + val reason: ProfilePreferenceSyncIssueReason, + ) : ProfilePreferenceCanonicalColumnsResult +} + +internal data class EncodedDirtyProfilePreferenceRow( + val valid: List, + val unsyncable: List, +) + +internal data class CurrentProfilePreferenceSyncState( + val serverRevision: Long, + val dirty: Boolean, + val normalizedPayload: JsonObject?, +) diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/ProfilePreferenceSyncPlanner.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/ProfilePreferenceSyncPlanner.kt new file mode 100644 index 000000000..99bea5044 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/ProfilePreferenceSyncPlanner.kt @@ -0,0 +1,113 @@ +package com.devil.phoenixproject.data.sync + +internal const val MAX_PROFILE_PREFERENCE_SECTION_BYTES = 256 * 1024 +internal const val MAX_PROFILE_PREFERENCE_REQUEST_BYTES = 512 * 1024 + +internal data class ProfilePreferencePushChunk( + val payload: PortalSyncPayload, + val ledger: Map, +) + +internal data class ProfilePreferencePushPlan( + val chunks: List, + val unsyncable: List, +) + +internal fun planProfilePreferencePushChunks( + basePayload: PortalSyncPayload, + mutations: List, +): ProfilePreferencePushPlan { + fun preferencePayload(items: List) = PortalSyncPayload( + deviceId = basePayload.deviceId, + platform = basePayload.platform, + lastSync = basePayload.lastSync, + profilePreferenceSections = items.map { it.wire }, + ) + + val sorted = mutations.sortedWith( + compareBy( + { it.key.localProfileId }, + { it.key.section.ordinal }, + { it.sentLocalGeneration }, + ), + ) + val duplicateKeys = sorted.groupingBy { it.key } + .eachCount() + .filterValues { it > 1 } + .keys + val valid = mutableListOf() + val issues = mutableListOf() + + sorted.forEach { mutation -> + if (mutation.key in duplicateKeys) { + issues += ProfilePreferenceSyncIssue( + key = mutation.key, + localGeneration = mutation.sentLocalGeneration, + reason = ProfilePreferenceSyncIssueReason.DUPLICATE_SECTION.name, + ) + return@forEach + } + + val oneElement = encodePortalSyncPayload(preferencePayload(listOf(mutation))) + val sectionBytes = oneElement.preferenceElementByteCount( + oneElement.preferenceElementSpans.single(), + ) + val reason = when { + sectionBytes > MAX_PROFILE_PREFERENCE_SECTION_BYTES -> + ProfilePreferenceSyncIssueReason.SECTION_TOO_LARGE + oneElement.rawBytes.size > MAX_PROFILE_PREFERENCE_REQUEST_BYTES -> + ProfilePreferenceSyncIssueReason.REQUEST_TOO_LARGE + else -> null + } + if (reason != null) { + issues += ProfilePreferenceSyncIssue( + key = mutation.key, + localGeneration = mutation.sentLocalGeneration, + reason = reason.name, + ) + return@forEach + } + valid += mutation + } + + val chunks = mutableListOf() + var current = mutableListOf() + fun emit() { + if (current.isEmpty()) return + val payload = preferencePayload(current) + val encoded = encodePortalSyncPayload(payload) + check(encoded.rawBytes.size <= MAX_PROFILE_PREFERENCE_REQUEST_BYTES) { + "PROFILE_PREFERENCE_PLANNER_OVERSIZED_REQUEST" + } + val ledger = current.associate { it.key to it.sentLocalGeneration } + check( + ledger.size == current.size && + ledger.size == payload.profilePreferenceSections.orEmpty().size, + ) { + "PROFILE_PREFERENCE_LEDGER_CARDINALITY_MISMATCH" + } + chunks += ProfilePreferencePushChunk( + payload = payload, + ledger = ledger, + ) + current = mutableListOf() + } + + valid.forEach { mutation -> + val candidate = current + mutation + val bytes = encodePortalSyncPayload(preferencePayload(candidate)).rawBytes.size + if (bytes > MAX_PROFILE_PREFERENCE_REQUEST_BYTES) emit() + current += mutation + } + emit() + + check( + chunks.all { + encodePortalSyncPayload(it.payload).rawBytes.size <= + MAX_PROFILE_PREFERENCE_REQUEST_BYTES + }, + ) { + "PROFILE_PREFERENCE_PLANNER_OVERSIZED_REQUEST" + } + return ProfilePreferencePushPlan(chunks = chunks, unsyncable = issues) +} diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/ProfilePreferenceSyncRepository.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/ProfilePreferenceSyncRepository.kt new file mode 100644 index 000000000..f9336567f --- /dev/null +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/ProfilePreferenceSyncRepository.kt @@ -0,0 +1,245 @@ +package com.devil.phoenixproject.data.sync + +import co.touchlab.kermit.Logger +import com.devil.phoenixproject.data.preferences.ProfilePreferencesCodec +import com.devil.phoenixproject.database.VitruvianDatabase +import com.devil.phoenixproject.domain.model.ProfilePreferenceSectionName + +/** Sync-layer boundary; bind only in SyncModule and do not expose through profile/domain APIs. */ +interface ProfilePreferenceSyncRepository { + suspend fun snapshotDirtySections(): ProfilePreferenceDirtySnapshot + + suspend fun applyPushOutcomes( + outcomes: List, + ): ProfilePreferenceSyncApplyReport + + suspend fun applyPulledSections( + sections: List, + ): ProfilePreferenceSyncApplyReport +} + +internal class SqlDelightProfilePreferenceSyncRepository( + private val database: VitruvianDatabase, + private val codec: ProfilePreferenceSyncCodec, + private val logCanonicalDivergence: (ProfilePreferenceSectionName) -> Unit = { section -> + Logger.w(tag = "ProfilePreferenceSync") { + "Repairing equal-revision canonical divergence for section=$section" + } + }, +) : ProfilePreferenceSyncRepository { + private val queries = database.vitruvianDatabaseQueries + + override suspend fun snapshotDirtySections(): ProfilePreferenceDirtySnapshot { + val encoded = queries.selectDirtyProfilePreferenceRows().executeAsList() + .map(codec::encodeDirtyRow) + return ProfilePreferenceDirtySnapshot( + valid = encoded.flatMap { it.valid }, + unsyncable = encoded.flatMap { it.unsyncable }, + ) + } + + override suspend fun applyPushOutcomes( + outcomes: List, + ): ProfilePreferenceSyncApplyReport { + var applied = 0 + var preserved = 0 + var invalid = 0 + outcomes.groupBy { it.key.localProfileId }.forEach { (_, profileOutcomes) -> + database.transaction { + profileOutcomes.forEach outcomeLoop@{ outcome -> + val canonical = outcome.canonical ?: return@outcomeLoop + if (canonical.key != outcome.key || + canonical.serverRevision != outcome.serverRevision + ) { + invalid++ + return@outcomeLoop + } + val decoded = codec.decodeCanonical(canonical) + if (decoded is ProfilePreferenceCanonicalColumnsResult.Invalid) { + invalid++ + return@outcomeLoop + } + val columns = + (decoded as ProfilePreferenceCanonicalColumnsResult.Valid).columns + if (applyCanonicalForGeneration(columns, outcome.sentLocalGeneration)) { + applied++ + } else if ( + advanceRevisionForNewerGeneration(columns, outcome.sentLocalGeneration) + ) { + preserved++ + } + } + } + } + return ProfilePreferenceSyncApplyReport( + applied = applied, + preservedNewerLocal = preserved, + invalid = invalid, + ) + } + + override suspend fun applyPulledSections( + sections: List, + ): ProfilePreferenceSyncApplyReport { + var applied = 0 + var unknown = 0 + var invalid = 0 + val validColumns = sections.mapNotNull { canonical -> + when (val decoded = codec.decodeCanonical(canonical)) { + is ProfilePreferenceCanonicalColumnsResult.Invalid -> { + invalid++ + null + } + is ProfilePreferenceCanonicalColumnsResult.Valid -> decoded.columns + } + } + validColumns.groupBy { it.key.localProfileId }.forEach { (profileId, columnsForProfile) -> + database.transaction { + if (queries.selectProfilePreferenceSyncRow(profileId).executeAsOneOrNull() == null) { + unknown += columnsForProfile.size + return@transaction + } + columnsForProfile.forEach { columns -> + if (applyPulledWhenClean(columns)) applied++ + } + } + } + return ProfilePreferenceSyncApplyReport( + applied = applied, + ignoredUnknownProfile = unknown, + invalid = invalid, + ) + } + + private fun applyCanonicalForGeneration( + columns: DecodedProfilePreferenceColumns, + sentLocalGeneration: Long, + ): Boolean { + when (val value = columns.value) { + is DecodedProfilePreferenceValue.Core -> queries.applyCoreCanonicalForGeneration( + body_weight_kg = value.value.bodyWeightKg.toDouble(), + weight_unit = value.value.weightUnit.name, + weight_increment = value.value.weightIncrement.toDouble(), + server_updated_at = columns.serverUpdatedAtEpochMs, + server_revision = columns.serverRevision, + profile_id = columns.key.localProfileId, + sent_local_generation = sentLocalGeneration, + ) + is DecodedProfilePreferenceValue.Rack -> queries.applyRackCanonicalForGeneration( + equipment_rack_json = ProfilePreferencesCodec.encodeRack(value.value), + server_updated_at = columns.serverUpdatedAtEpochMs, + server_revision = columns.serverRevision, + profile_id = columns.key.localProfileId, + sent_local_generation = sentLocalGeneration, + ) + is DecodedProfilePreferenceValue.Workout -> queries.applyWorkoutCanonicalForGeneration( + workout_preferences_json = ProfilePreferencesCodec.encodeWorkout(value.value), + server_updated_at = columns.serverUpdatedAtEpochMs, + server_revision = columns.serverRevision, + profile_id = columns.key.localProfileId, + sent_local_generation = sentLocalGeneration, + ) + is DecodedProfilePreferenceValue.Led -> queries.applyLedCanonicalForGeneration( + led_color_scheme_id = value.value.colorScheme.toLong(), + led_preferences_json = ProfilePreferencesCodec.encodeLed(value.value), + server_updated_at = columns.serverUpdatedAtEpochMs, + server_revision = columns.serverRevision, + profile_id = columns.key.localProfileId, + sent_local_generation = sentLocalGeneration, + ) + is DecodedProfilePreferenceValue.Vbt -> queries.applyVbtCanonicalForGeneration( + vbt_enabled = if (value.value.enabled) 1L else 0L, + vbt_preferences_json = ProfilePreferencesCodec.encodeVbt(value.value), + server_updated_at = columns.serverUpdatedAtEpochMs, + server_revision = columns.serverRevision, + profile_id = columns.key.localProfileId, + sent_local_generation = sentLocalGeneration, + ) + } + return changedRowCount() + } + + private fun advanceRevisionForNewerGeneration( + columns: DecodedProfilePreferenceColumns, + sentLocalGeneration: Long, + ): Boolean { + when (columns.value) { + is DecodedProfilePreferenceValue.Core -> queries.advanceCoreRevisionForNewerGeneration( + server_revision = columns.serverRevision, + profile_id = columns.key.localProfileId, + sent_local_generation = sentLocalGeneration, + ) + is DecodedProfilePreferenceValue.Rack -> queries.advanceRackRevisionForNewerGeneration( + server_revision = columns.serverRevision, + profile_id = columns.key.localProfileId, + sent_local_generation = sentLocalGeneration, + ) + is DecodedProfilePreferenceValue.Workout -> + queries.advanceWorkoutRevisionForNewerGeneration( + server_revision = columns.serverRevision, + profile_id = columns.key.localProfileId, + sent_local_generation = sentLocalGeneration, + ) + is DecodedProfilePreferenceValue.Led -> queries.advanceLedRevisionForNewerGeneration( + server_revision = columns.serverRevision, + profile_id = columns.key.localProfileId, + sent_local_generation = sentLocalGeneration, + ) + is DecodedProfilePreferenceValue.Vbt -> queries.advanceVbtRevisionForNewerGeneration( + server_revision = columns.serverRevision, + profile_id = columns.key.localProfileId, + sent_local_generation = sentLocalGeneration, + ) + } + return changedRowCount() + } + + private fun applyPulledWhenClean(columns: DecodedProfilePreferenceColumns): Boolean { + val row = queries.selectProfilePreferenceSyncRow(columns.key.localProfileId) + .executeAsOneOrNull() + ?: return false + if (codec.hasCanonicalDivergence(row, columns)) { + logCanonicalDivergence(columns.section) + } + when (val value = columns.value) { + is DecodedProfilePreferenceValue.Core -> queries.applyPulledCoreWhenClean( + body_weight_kg = value.value.bodyWeightKg.toDouble(), + weight_unit = value.value.weightUnit.name, + weight_increment = value.value.weightIncrement.toDouble(), + server_updated_at = columns.serverUpdatedAtEpochMs, + server_revision = columns.serverRevision, + profile_id = columns.key.localProfileId, + ) + is DecodedProfilePreferenceValue.Rack -> queries.applyPulledRackWhenClean( + equipment_rack_json = ProfilePreferencesCodec.encodeRack(value.value), + server_updated_at = columns.serverUpdatedAtEpochMs, + server_revision = columns.serverRevision, + profile_id = columns.key.localProfileId, + ) + is DecodedProfilePreferenceValue.Workout -> queries.applyPulledWorkoutWhenClean( + workout_preferences_json = ProfilePreferencesCodec.encodeWorkout(value.value), + server_updated_at = columns.serverUpdatedAtEpochMs, + server_revision = columns.serverRevision, + profile_id = columns.key.localProfileId, + ) + is DecodedProfilePreferenceValue.Led -> queries.applyPulledLedWhenClean( + led_color_scheme_id = value.value.colorScheme.toLong(), + led_preferences_json = ProfilePreferencesCodec.encodeLed(value.value), + server_updated_at = columns.serverUpdatedAtEpochMs, + server_revision = columns.serverRevision, + profile_id = columns.key.localProfileId, + ) + is DecodedProfilePreferenceValue.Vbt -> queries.applyPulledVbtWhenClean( + vbt_enabled = if (value.value.enabled) 1L else 0L, + vbt_preferences_json = ProfilePreferencesCodec.encodeVbt(value.value), + server_updated_at = columns.serverUpdatedAtEpochMs, + server_revision = columns.serverRevision, + profile_id = columns.key.localProfileId, + ) + } + return changedRowCount() + } + + private fun changedRowCount(): Boolean = + queries.selectChangedRowCount().executeAsOne() > 0L +} diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/SyncManager.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/SyncManager.kt index 421269eaf..81536620a 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/SyncManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/SyncManager.kt @@ -12,12 +12,14 @@ import com.devil.phoenixproject.data.repository.UserProfileRepository import com.devil.phoenixproject.data.repository.VelocityOneRepMaxRepository import com.devil.phoenixproject.domain.model.CharacterClass import com.devil.phoenixproject.domain.model.IntegrationProvider +import com.devil.phoenixproject.domain.model.ProfilePreferenceSectionName import com.devil.phoenixproject.domain.model.RpgProfile import com.devil.phoenixproject.domain.model.WorkoutSession import com.devil.phoenixproject.domain.model.currentTimeMillis import com.devil.phoenixproject.domain.premium.RpgAttributeEngine import com.devil.phoenixproject.getPlatform import com.devil.phoenixproject.isIosPlatform +import kotlin.coroutines.cancellation.CancellationException import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive @@ -124,6 +126,195 @@ object SyncConfig { const val RATE_LIMIT_WINDOW_MS = 60_000L } +private val PROFILE_PREFERENCE_REASON_NAMES = + ProfilePreferenceSyncIssueReason.entries.mapTo(mutableSetOf()) { it.name } + +private fun safeProfilePreferenceReason(reason: String): String = + reason.takeIf { it in PROFILE_PREFERENCE_REASON_NAMES } + ?: "INVALID_PROFILE_PREFERENCE_DIAGNOSTIC" + +internal enum class ProfilePreferenceLocalFailureStage { + SNAPSHOT, + MUTATION_MAPPING, + CHUNK_PLANNING, + RESPONSE_MAPPING, + OUTCOME_APPLY, + PULL_RESPONSE_MAPPING, + PULL_APPLY, +} + +internal fun profilePreferenceIssueLogLine(issue: ProfilePreferenceSyncIssue): String = + "PROFILE_PREFERENCE_NOT_SENT section=${issue.key.section.name} " + + "reason=${safeProfilePreferenceReason(issue.reason)}" + +internal fun profilePreferenceMetadataDeferredLogLine( + key: ProfilePreferenceSectionKey, +): String = "PROFILE_PREFERENCE_NOT_SENT section=${key.section.name} " + + "reason=PROFILE_METADATA_NOT_SENT" + +internal fun profilePreferenceDuplicateResultLogLine( + key: ProfilePreferenceSectionKey, +): String = "PROFILE_PREFERENCE_DUPLICATE_RESULT section=${key.section.name}" + +internal fun profilePreferenceInvalidCanonicalLogLine( + invalid: ProfilePreferenceCanonicalDecodeResult.Invalid, +): String = "PROFILE_PREFERENCE_INVALID_CANONICAL " + + "reason=${safeProfilePreferenceReason(invalid.reason)}" + +internal enum class ProfilePreferencePullDiagnosticCategory { + INVALID_CANONICAL, + DUPLICATE_KEY, + LATER_PAGE_IGNORED, + UNKNOWN_PROFILE, + REPOSITORY_INVALID, +} + +internal data class ProfilePreferencePullPlan( + val valid: List, + val invalidCanonicalCount: Int, + val duplicateKeyCount: Int, +) + +private fun profilePreferencePullEnvelopeKey( + dto: PortalProfilePreferenceSectionCanonicalDto, +): ProfilePreferenceSectionKey? { + val section = ProfilePreferenceSectionName.entries.firstOrNull { it.name == dto.section } + ?: return null + return ProfilePreferenceSectionKey(dto.localProfileId, section) +} + +internal fun planProfilePreferencePullSections( + dtos: List, +): ProfilePreferencePullPlan { + val keyCounts = dtos.mapNotNull(::profilePreferencePullEnvelopeKey) + .groupingBy { it } + .eachCount() + val duplicateKeys = keyCounts.filterValues { it > 1 }.keys + val decoded = dtos.map(PortalPullAdapter::toCanonicalProfilePreferenceSection) + return ProfilePreferencePullPlan( + valid = decoded + .filterIsInstance() + .map { it.section } + .filterNot { it.key in duplicateKeys }, + invalidCanonicalCount = decoded.count { + it is ProfilePreferenceCanonicalDecodeResult.Invalid + }, + duplicateKeyCount = duplicateKeys.size, + ) +} + +internal fun profilePreferencePullCountLogLine( + category: ProfilePreferencePullDiagnosticCategory, + count: Int, +): String = "PROFILE_PREFERENCE_PULL category=${category.name} count=$count" + +internal fun profilePreferenceChunkFailureLogLine(error: Throwable?): String { + val status = (error as? PortalApiException)?.statusCode?.toString() ?: "UNKNOWN" + return "PROFILE_PREFERENCE_CHUNK_FAILED status=$status" +} + +internal fun profilePreferenceLocalFailureLogLine( + stage: ProfilePreferenceLocalFailureStage, +): String = "PROFILE_PREFERENCE_LOCAL_FAILURE stage=${stage.name}" + +internal suspend fun isolateProfilePreferenceFailure( + stage: ProfilePreferenceLocalFailureStage, + onFailure: (String) -> Unit, + block: suspend () -> T, +): T? = try { + block() +} catch (cancelled: CancellationException) { + throw cancelled +} catch (_: Exception) { + onFailure(profilePreferenceLocalFailureLogLine(stage)) + null +} + +private data class PreferenceOutcomeCandidate( + val key: ProfilePreferenceSectionKey, + val serverRevision: Long, + val canonical: CanonicalProfilePreferenceSection, + val rejectionReason: String?, +) + +private val PROFILE_PREFERENCE_REJECTION_REASONS = setOf( + "REVISION_CONFLICT", + "VALIDATION_FAILED", + "UNSUPPORTED_SECTION", + "UNSUPPORTED_DOCUMENT_VERSION", + "UNKNOWN_PROFILE", + "SECTION_TOO_LARGE", + "DUPLICATE_SECTION", +) + +private fun responseKey( + localProfileId: String, + section: String, +): ProfilePreferenceSectionKey? { + val parsed = ProfilePreferenceSectionName.entries.firstOrNull { it.name == section } + ?: return null + return ProfilePreferenceSectionKey(localProfileId, parsed) +} + +internal fun buildProfilePreferencePushOutcomes( + response: PortalSyncPushResponse, + ledger: Map, +): List { + val candidates = mutableListOf() + val responseCounts = mutableMapOf() + response.canonicalProfilePreferenceSections.forEach { dto -> + val key = responseKey(dto.localProfileId, dto.section) ?: return@forEach + if (key !in ledger) return@forEach + responseCounts[key] = responseCounts.getOrElse(key) { 0 } + 1 + val decoded = PortalPullAdapter.toCanonicalProfilePreferenceSection(dto) + if (decoded is ProfilePreferenceCanonicalDecodeResult.Valid && decoded.section.key == key) { + candidates += PreferenceOutcomeCandidate( + key = key, + serverRevision = decoded.section.serverRevision, + canonical = decoded.section, + rejectionReason = null, + ) + } + } + response.profilePreferenceRejections.forEach { rejection -> + val key = responseKey(rejection.localProfileId, rejection.section) ?: return@forEach + if (key !in ledger) return@forEach + responseCounts[key] = responseCounts.getOrElse(key) { 0 } + 1 + if (rejection.reason !in PROFILE_PREFERENCE_REJECTION_REASONS) return@forEach + if (rejection.reason != "REVISION_CONFLICT") return@forEach + val canonicalDto = rejection.canonicalSection ?: return@forEach + val canonical = PortalPullAdapter.toCanonicalProfilePreferenceSection(canonicalDto) + val decodedCanonical = (canonical as? ProfilePreferenceCanonicalDecodeResult.Valid) + ?.section + ?: return@forEach + if (decodedCanonical.key != key || + decodedCanonical.serverRevision != rejection.serverRevision + ) { + return@forEach + } + candidates += PreferenceOutcomeCandidate( + key = key, + serverRevision = rejection.serverRevision, + canonical = decodedCanonical, + rejectionReason = rejection.reason, + ) + } + return candidates.groupBy(PreferenceOutcomeCandidate::key).mapNotNull { (key, entries) -> + if (entries.size != 1 || responseCounts[key] != 1) { + Logger.w("SyncManager") { profilePreferenceDuplicateResultLogLine(key) } + return@mapNotNull null + } + val candidate = entries.single() + ProfilePreferencePushOutcome( + key = key, + sentLocalGeneration = ledger.getValue(key), + serverRevision = candidate.serverRevision, + canonical = candidate.canonical, + rejectionReason = candidate.rejectionReason, + ) + } +} + class SyncManager( private val apiClient: PortalApiClient, private val tokenStorage: PortalTokenStorage, @@ -131,9 +322,11 @@ class SyncManager( private val gamificationRepository: GamificationRepository, private val repMetricRepository: RepMetricRepository, private val userProfileRepository: UserProfileRepository, + private val profilePreferenceSyncRepository: ProfilePreferenceSyncRepository, private val externalActivityRepository: ExternalActivityRepository, private val velocityOneRepMaxRepository: VelocityOneRepMaxRepository, private val rateLimiter: ClientRateLimiter = ClientRateLimiter(), + private val isProfilePreferenceMigrationReady: () -> Boolean, ) { companion object { /** @@ -562,20 +755,6 @@ class SyncManager( val userId = tokenStorage.currentUser.value?.id ?: return Result.failure(PortalApiException("Not authenticated", null, 401)) - // fix(audit #9): self-throttle to match server 10/min limit so a - // runaway retry loop fails fast locally instead of hammering the - // Edge Function for HTTP 429 responses. - if (!rateLimiter.tryAcquire("push", SyncConfig.PUSH_RATE_LIMIT_PER_MIN)) { - return Result.failure( - PortalApiException( - "Client rate limit exceeded for push (" + - "${SyncConfig.PUSH_RATE_LIMIT_PER_MIN}/min). Try again shortly.", - null, - 429, - ), - ) - } - val deviceId = tokenStorage.getDeviceId() val lastSync = tokenStorage.getLastSyncTimestamp() val platform = getPlatformName() @@ -949,7 +1128,7 @@ class SyncManager( personalRecords = personalRecordDtos, ) rejectDuplicatePushPayloadKeys(payload)?.let { return it } - val result = apiClient.pushPortalPayload(payload) + val result = pushPayloadWithRateLimit(payload) if (result.isFailure) return result lastResponse = result.getOrThrow() // Single-batch success - reset retry tracking @@ -1001,7 +1180,7 @@ class SyncManager( ) rejectDuplicatePushPayloadKeys(payload)?.let { return it } - val result = apiClient.pushPortalPayload(payload) + val result = pushPayloadWithRateLimit(payload) if (result.isFailure) { val error = result.exceptionOrNull() val batchSessionIds = batchSessions.map { it.id }.take(3) @@ -1057,6 +1236,14 @@ class SyncManager( lastFailedBatchHash = null } + val sentMetadataProfileIds = profileDtos.mapTo(linkedSetOf()) { it.id } + pushDirtyProfilePreferences( + deviceId = deviceId, + platform = platform, + lastSync = lastSync, + sentMetadataProfileIds = sentMetadataProfileIds, + ) + // Mark external activities as synced based on server acknowledgement. // Only mark activities the server confirmed it persisted — prevents silently // dropping activities that the server soft-failed on. @@ -1114,6 +1301,105 @@ class SyncManager( // No updateServerIds() -- portal uses client-provided UUIDs } + private suspend fun pushPayloadWithRateLimit( + payload: PortalSyncPayload, + ): Result { + if (!rateLimiter.tryAcquire("push", SyncConfig.PUSH_RATE_LIMIT_PER_MIN)) { + return Result.failure( + PortalApiException( + "Client rate limit exceeded for push " + + "(${SyncConfig.PUSH_RATE_LIMIT_PER_MIN}/min). Try again shortly.", + statusCode = 429, + ), + ) + } + return apiClient.pushPortalPayload(payload) + } + + private suspend fun pushDirtyProfilePreferences( + deviceId: String, + platform: String, + lastSync: Long, + sentMetadataProfileIds: Set, + ) { + if (!isProfilePreferenceMigrationReady()) return + val safeFailureLogger: (String) -> Unit = { line -> + Logger.w("SyncManager") { line } + } + val snapshot = isolateProfilePreferenceFailure( + ProfilePreferenceLocalFailureStage.SNAPSHOT, + safeFailureLogger, + ) { + profilePreferenceSyncRepository.snapshotDirtySections() + } ?: return + snapshot.unsyncable.forEach { issue -> + Logger.w("SyncManager") { profilePreferenceIssueLogLine(issue) } + } + val (eligible, deferred) = snapshot.valid.partition { + it.key.localProfileId in sentMetadataProfileIds + } + deferred.forEach { section -> + Logger.i("SyncManager") { profilePreferenceMetadataDeferredLogLine(section.key) } + } + val prepared = isolateProfilePreferenceFailure( + ProfilePreferenceLocalFailureStage.MUTATION_MAPPING, + safeFailureLogger, + ) { + eligible.map(PortalSyncAdapter::toPortalProfilePreferenceMutation) + } ?: return + if (prepared.isEmpty()) return + + val base = PortalSyncPayload( + deviceId = deviceId, + platform = platform, + lastSync = lastSync, + ) + val plan = isolateProfilePreferenceFailure( + ProfilePreferenceLocalFailureStage.CHUNK_PLANNING, + safeFailureLogger, + ) { + planProfilePreferencePushChunks(base, prepared) + } ?: return + plan.unsyncable.forEach { issue -> + Logger.w("SyncManager") { profilePreferenceIssueLogLine(issue) } + } + + for (chunk in plan.chunks) { + val result = pushPayloadWithRateLimit(chunk.payload) + if (result.isFailure) { + Logger.w("SyncManager") { + profilePreferenceChunkFailureLogLine(result.exceptionOrNull()) + } + return + } + val response = result.getOrThrow() + if (response.profilePreferencesAccepted != true) { + Logger.i("SyncManager") { + "Backend did not acknowledge profile preference support" + } + return + } + val outcomes = isolateProfilePreferenceFailure( + ProfilePreferenceLocalFailureStage.RESPONSE_MAPPING, + safeFailureLogger, + ) { + buildProfilePreferencePushOutcomes(response, chunk.ledger) + } ?: return + if (outcomes.isNotEmpty()) { + isolateProfilePreferenceFailure( + ProfilePreferenceLocalFailureStage.OUTCOME_APPLY, + safeFailureLogger, + ) { + val report = profilePreferenceSyncRepository.applyPushOutcomes(outcomes) + if (report.applied > 0) { + userProfileRepository.refreshProfiles() + } + report + } ?: return + } + } + } + /** * Pull portal data and merge into local database with parity-based sync. * @@ -1284,6 +1570,7 @@ class SyncManager( pullResponse.cycles.size + pullResponse.badges.size + pullResponse.personalRecords.size + + (pullResponse.profilePreferenceSections?.size ?: 0) + (if (pullResponse.rpgAttributes != null) 1 else 0) + (if (pullResponse.gamificationStats != null) 1 else 0) + pullResponse.externalActivities.size @@ -1314,8 +1601,13 @@ class SyncManager( } } - // Merge this page atomically - val mergeResult = mergePullPage(pullResponse, lastSync, mergeProfileId) + // Merge this page in preference-first repository order + val mergeResult = mergePullPage( + pullResponse = pullResponse, + lastSync = lastSync, + mergeProfileId = mergeProfileId, + isFirstPage = pagesProcessed == 1, + ) if (mergeResult.isFailure) { // Map Result to Result for consistent return type return Result.failure(mergeResult.exceptionOrNull() ?: PortalApiException("Merge failed")) @@ -1351,20 +1643,98 @@ class SyncManager( return Result.success(finalSyncTime) } + private suspend fun applyPulledProfilePreferences( + dtos: List?, + isFirstPage: Boolean, + ) { + val sections = dtos.orEmpty() + if (sections.isEmpty()) return + if (!isFirstPage) { + Logger.w("SyncManager") { + profilePreferencePullCountLogLine( + ProfilePreferencePullDiagnosticCategory.LATER_PAGE_IGNORED, + sections.size, + ) + } + return + } + if (!isProfilePreferenceMigrationReady()) return + + val safeFailureLogger: (String) -> Unit = { line -> + Logger.w("SyncManager") { line } + } + val plan = isolateProfilePreferenceFailure( + ProfilePreferenceLocalFailureStage.PULL_RESPONSE_MAPPING, + safeFailureLogger, + ) { + planProfilePreferencePullSections(sections) + } ?: return + if (plan.invalidCanonicalCount > 0) { + Logger.w("SyncManager") { + profilePreferencePullCountLogLine( + ProfilePreferencePullDiagnosticCategory.INVALID_CANONICAL, + plan.invalidCanonicalCount, + ) + } + } + if (plan.duplicateKeyCount > 0) { + Logger.w("SyncManager") { + profilePreferencePullCountLogLine( + ProfilePreferencePullDiagnosticCategory.DUPLICATE_KEY, + plan.duplicateKeyCount, + ) + } + } + if (plan.valid.isEmpty()) return + + val report = isolateProfilePreferenceFailure( + ProfilePreferenceLocalFailureStage.PULL_APPLY, + safeFailureLogger, + ) { + val applyReport = profilePreferenceSyncRepository.applyPulledSections(plan.valid) + if (applyReport.applied > 0) { + userProfileRepository.refreshProfiles() + } + applyReport + } ?: return + if (report.ignoredUnknownProfile > 0) { + Logger.i("SyncManager") { + profilePreferencePullCountLogLine( + ProfilePreferencePullDiagnosticCategory.UNKNOWN_PROFILE, + report.ignoredUnknownProfile, + ) + } + } + if (report.invalid > 0) { + Logger.w("SyncManager") { + profilePreferencePullCountLogLine( + ProfilePreferencePullDiagnosticCategory.REPOSITORY_INVALID, + report.invalid, + ) + } + } + } + /** - * Merge a single pull page atomically into local database. - * Extracted from pullRemoteChangesWithResult for pagination support. + * Merge one pull page in preference-first order. + * + * Preference sections commit through ProfilePreferenceSyncRepository before ordinary + * entities. SyncRepository's ordinary merge owns its own transaction, while session LWW, + * preferences, notes, RPG, and external activities may use separate repository transactions. + * A later ordinary failure therefore does not roll back an earlier preference commit. + * The caller leaves lastSync unchanged and retries the page; revision guards make replay + * idempotent and dirty-section predicates preserve concurrent local edits. */ private suspend fun mergePullPage( pullResponse: PortalSyncPullResponse, lastSync: Long, mergeProfileId: String, + isFirstPage: Boolean, ): Result { - // ==================================================================================== - // ATOMIC MERGE: All SyncRepository-managed entities are merged in a single transaction. - // This ensures all-or-nothing semantics: if any entity type fails, the entire page - // rolls back to prevent partial state. - // ==================================================================================== + applyPulledProfilePreferences( + dtos = pullResponse.profilePreferenceSections, + isFirstPage = isFirstPage, + ) // 1. Prepare sessions with exercise lookup (pre-transaction to avoid DB calls in transaction) var unmatchedExerciseCount = 0 @@ -1456,18 +1826,15 @@ class SyncManager( } .toMap() - // 3. Execute atomic merge (all or nothing). Sessions are merged via - // the LWW path (mergeSessionsLww) when SYNC_LWW_ENABLED is implicit - // through the presence of incoming updatedAt; falls back to the - // legacy INSERT OR IGNORE behavior when the map has no entries - // (older Edge Function payloads pre-Phase-3.2). + // 3. Execute ordinary repository merges. Sessions use the LWW path when incoming + // updatedAt values are present and fall back to legacy INSERT OR IGNORE otherwise. + // These calls may commit independently of the preference repository call above. val sessionsHaveUpdatedAt = sessionUpdatedAtById.values.any { it > 0L } try { if (sessionsHaveUpdatedAt) { syncRepository.mergeSessionsLww(mobileSessions, sessionUpdatedAtById) - // Routines/cycles/badges/stats/PRs still go through the atomic - // path. Pass an empty session list to skip the legacy - // INSERT OR IGNORE branch (already handled by LWW). + // Routines/cycles/badges/stats/PRs still use SyncRepository's transaction. + // Pass an empty session list because LWW already handled sessions. syncRepository.mergeAllPullData( sessions = emptyList(), routines = pullResponse.routines, @@ -1491,8 +1858,8 @@ class SyncManager( ) } - // Phase 3.5: persist session-level notes after the atomic merge - // succeeds. Kept outside the main transaction so a notes-table + // Phase 3.5: persist session-level notes after ordinary repository merges + // succeed. Kept outside the main transaction so a notes-table // failure cannot roll back session data. if (sessionNotesMap.isNotEmpty()) { try { @@ -1506,22 +1873,21 @@ class SyncManager( } Logger.d("SyncManager") { - "Atomic merge complete: ${mobileSessions.size} sessions (${mobileSessions.count { it.exerciseId != null }} with exerciseId), " + + "Ordinary pull merge complete: ${mobileSessions.size} sessions (${mobileSessions.count { it.exerciseId != null }} with exerciseId), " + "${pullResponse.routines.size} routines, ${pullResponse.cycles.size} cycles, " + "${pullResponse.badges.size} badges, ${prDtos.size} PRs, " + "${sessionNotesMap.size} session notes" } } catch (e: Exception) { - Logger.e(e) { "Atomic merge failed - transaction rolled back. No entities were persisted." } + Logger.e(e) { + "Ordinary pull merge failed; lastSync will not advance and earlier per-repository " + + "merges may remain for idempotent retry." + } return Result.failure(PortalApiException("Pull merge failed: ${e.message}")) } - // ==================================================================================== - // NON-ATOMIC MERGES: RPG attributes and external activities are managed by separate - // repositories. They are merged after the atomic transaction since they have different - // conflict resolution strategies and don't need to be atomic with core sync data. - // If these fail, the core sync data is still preserved. - // ==================================================================================== + // RPG attributes and external activities use separate repositories and conflict rules. + // Failures here do not roll back already committed ordinary or preference data. try { // RPG attributes — server wins (overwrite local) @@ -1581,7 +1947,7 @@ class SyncManager( } } catch (e: Exception) { Logger.w(e) { - "Non-atomic post-merge (RPG/external activities) failed; " + + "Separate post-merge repository work (RPG/external activities) failed; " + "non-fatal, core sync data is preserved." } } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/SyncModels.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/SyncModels.kt index 63cff9875..eef339878 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/SyncModels.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/SyncModels.kt @@ -4,6 +4,70 @@ import kotlinx.serialization.Serializable // === Shared Data Structures === +data class ProfilePreferenceSectionKey( + val localProfileId: String, + val section: com.devil.phoenixproject.domain.model.ProfilePreferenceSectionName, +) + +data class ProfilePreferenceSectionSyncDto( + val key: ProfilePreferenceSectionKey, + val documentVersion: Int, + val baseRevision: Long, + val clientModifiedAtEpochMs: Long, + val localGeneration: Long, + val payload: kotlinx.serialization.json.JsonObject, +) + +data class PreparedProfilePreferenceMutation( + val wire: PortalProfilePreferenceSectionMutationDto, + val key: ProfilePreferenceSectionKey, + val sentLocalGeneration: Long, +) + +data class CanonicalProfilePreferenceSection( + val key: ProfilePreferenceSectionKey, + val documentVersion: Int, + val serverRevision: Long, + val serverUpdatedAtEpochMs: Long, + val payload: kotlinx.serialization.json.JsonObject, +) + +data class ProfilePreferencePushOutcome( + val key: ProfilePreferenceSectionKey, + val sentLocalGeneration: Long, + val serverRevision: Long, + val canonical: CanonicalProfilePreferenceSection?, + val rejectionReason: String?, +) + +data class ProfilePreferenceSyncApplyReport( + val applied: Int = 0, + val preservedNewerLocal: Int = 0, + val ignoredUnknownProfile: Int = 0, + val invalid: Int = 0, +) + +data class ProfilePreferenceSyncIssue( + val key: ProfilePreferenceSectionKey, + val localGeneration: Long, + val reason: String, +) + +data class ProfilePreferenceDirtySnapshot( + val valid: List, + val unsyncable: List, +) + +sealed interface ProfilePreferenceCanonicalDecodeResult { + data class Valid(val section: CanonicalProfilePreferenceSection) : ProfilePreferenceCanonicalDecodeResult + + data class Invalid( + val localProfileId: String, + val section: String, + val reason: String, + ) : ProfilePreferenceCanonicalDecodeResult +} + // IdMappings is used by SyncRepository / SqlDelightSyncRepository to stamp server-assigned // UUIDs back onto locally-created rows after a successful push. It is NOT part of the wire // format for the current Edge Functions (which use client-provided UUIDs); it exists to @@ -31,7 +95,7 @@ data class PortalUser(val id: String, val email: String, val displayName: String // === Entity DTOs === // NOTE: These types are internal data-transfer objects used between the sync layer and the // repository / merge logic. They are NOT wire-format types — the actual HTTP request/response -// bodies are defined in PortalSyncDtos.kt (PortalSyncPushRequest, PortalSyncPullResponse, etc.). +// bodies are defined in PortalSyncDtos.kt (PortalSyncPayload, PortalSyncPullResponse, etc.). @Serializable data class WorkoutSessionSyncDto( diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/di/DataModule.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/di/DataModule.kt index 6b72ce604..85f7c83ef 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/di/DataModule.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/di/DataModule.kt @@ -15,8 +15,16 @@ import com.devil.phoenixproject.data.integration.SqlDelightExternalRoutineReposi import com.devil.phoenixproject.data.integration.SqlDelightIntegrationSyncCursorRepository import com.devil.phoenixproject.data.local.DatabaseFactory import com.devil.phoenixproject.data.local.ExerciseImporter +import com.devil.phoenixproject.data.preferences.LegacyProfilePreferencesReader +import com.devil.phoenixproject.data.preferences.ProfileLocalSafetyStore +import com.devil.phoenixproject.data.preferences.SettingsLegacyProfilePreferencesReader +import com.devil.phoenixproject.data.preferences.SettingsProfileLocalSafetyStore import com.devil.phoenixproject.data.repository.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import org.koin.dsl.module +import org.koin.dsl.onClose val dataModule = module { // Database @@ -33,7 +41,19 @@ val dataModule = module { single { SqlDelightWorkoutRepository(get(), get()) } single { SqlDelightPersonalRecordRepository(get()) } single { SqlDelightGamificationRepository(get()) } - single { SqlDelightUserProfileRepository(get()) } + single { SqlDelightProfilePreferencesRepository(get()) } + single { SettingsProfileLocalSafetyStore(get()) } + single { SettingsLegacyProfilePreferencesReader(get(), get()) } + single { ProfileScopedDataMerger(get()) } + single { + SqlDelightUserProfileRepository( + database = get(), + profilePreferencesRepository = get(), + profileLocalSafetyStore = get(), + gamificationRepository = get(), + profileScopedDataMerger = get(), + ) + } // Rep Metrics Repository single { SqlDelightRepMetricRepository(get()) } @@ -45,7 +65,14 @@ val dataModule = module { single { SqlDelightTrainingCycleRepository(get()) } single { SqlDelightCompletedSetRepository(get()) } single { SqlDelightProgressionRepository(get()) } - single { SettingsEquipmentRackRepository(get()) } + single { + ProfileEquipmentRackRepository( + profiles = get(), + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + ) + } onClose { repository -> + (repository as? ProfileEquipmentRackRepository)?.close() + } // Smart Suggestions Repository single { SqlDelightSmartSuggestionsRepository(get()) } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/di/DomainModule.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/di/DomainModule.kt index 52bba93ef..72290f3e9 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/di/DomainModule.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/di/DomainModule.kt @@ -1,6 +1,7 @@ package com.devil.phoenixproject.di import com.devil.phoenixproject.data.migration.MigrationManager +import com.devil.phoenixproject.data.migration.RequiredMigrationGate import com.devil.phoenixproject.data.preferences.PreferencesManager import com.devil.phoenixproject.data.preferences.SettingsPreferencesManager import com.devil.phoenixproject.data.repository.ExerciseRepository @@ -22,6 +23,7 @@ import com.devil.phoenixproject.domain.usecase.ProgressionUseCase import com.devil.phoenixproject.domain.usecase.RecommendWeightAdjustmentUseCase import com.devil.phoenixproject.domain.usecase.RecordPersonalMvtSampleUseCase import com.devil.phoenixproject.domain.usecase.RepCounterFromMachine +import com.devil.phoenixproject.domain.usecase.ResolveCurrentOneRepMaxUseCase import com.devil.phoenixproject.domain.usecase.ResolveRoutineScalingBaselineUseCase import com.devil.phoenixproject.domain.usecase.ResolveRoutineWeightsUseCase import com.devil.phoenixproject.domain.usecase.RoutineTimeEstimator @@ -48,6 +50,7 @@ val domainModule = module { // Assessment single { AssessmentEngine() } + single { ResolveCurrentOneRepMaxUseCase(get(), get(), get()) } // Velocity-based 1RM (issue #517) single { MvtProvider() } @@ -87,10 +90,22 @@ val domainModule = module { ) } - // Migration — settings is passed so one-time repairs are gated by a persisted version flag - single { MigrationManager(get(), get(), get(), settings = get()) } + // Required profile preference copy runs before the existing non-critical repair passes. + single { + MigrationManager( + database = get(), + userProfileRepository = get(), + gamificationRepository = get(), + settings = get(), + profilePreferencesRepository = get(), + profileLocalSafetyStore = get(), + legacyProfilePreferencesReader = get(), + profileScopedDataMerger = get(), + ) + } + single { get() } // Voice / Safe Word (Issue #141) // SafeWordListenerFactory is provided by platformModule - single { SafeWordDetectionManager(get(), get()) } + single { SafeWordDetectionManager(get(), get()) } } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/di/KoinInit.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/di/KoinInit.kt index 94a01a442..fdf4f6db7 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/di/KoinInit.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/di/KoinInit.kt @@ -40,14 +40,13 @@ internal fun doInitKoinInternal() { * This mirrors Android's VitruvianApp.onCreate() migration call. */ fun runMigrations() { - Logger.i { "iOS: Running migrations..." } + Logger.i { "iOS: Starting required migration gate..." } try { val koin = KoinPlatform.getKoin() val migrationManager = koin.get() migrationManager.checkAndRunMigrations() - Logger.i { "iOS: Migrations completed" } + Logger.i { "iOS: Required migration gate started" } } catch (e: Exception) { - // Log error but don't crash - migrations are best effort - Logger.e(e) { "Failed to run migrations on iOS" } + Logger.e(e) { "Failed to start required migrations on iOS" } } } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/di/PresentationModule.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/di/PresentationModule.kt index d504731e1..a64172022 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/di/PresentationModule.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/di/PresentationModule.kt @@ -13,6 +13,8 @@ import com.devil.phoenixproject.presentation.viewmodel.ExternalProgramsViewModel import com.devil.phoenixproject.presentation.viewmodel.ExternalRoutinesViewModel import com.devil.phoenixproject.presentation.viewmodel.GamificationViewModel import com.devil.phoenixproject.presentation.viewmodel.IntegrationsViewModel +import com.devil.phoenixproject.presentation.viewmodel.ProfileViewModel +import com.devil.phoenixproject.presentation.viewmodel.ProfileSwitcherViewModel import com.devil.phoenixproject.presentation.viewmodel.ThemeViewModel import com.devil.phoenixproject.ui.sync.LinkAccountViewModel import org.koin.dsl.module @@ -31,6 +33,17 @@ val presentationModule = module { factory { ExternalProgramsViewModel(get(), get(), get()) } factory { ExternalMeasurementsViewModel(get(), get()) } factory { AssessmentViewModel(get(), get(), get()) } + factory { + ProfileViewModel( + profiles = get(), + exercises = get(), + workouts = get(), + personalRecords = get(), + resolveCurrentOneRepMax = get(), + externalMeasurements = get(), + ) + } + factory { ProfileSwitcherViewModel(profiles = get()) } // ThemeViewModel as singleton - app-wide theme state that must persist single { ThemeViewModel(get()) } // EulaViewModel as singleton - tracks EULA acceptance across app lifecycle diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/di/SyncModule.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/di/SyncModule.kt index 0bcac6d22..0479a5ebd 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/di/SyncModule.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/di/SyncModule.kt @@ -4,8 +4,12 @@ import com.devil.phoenixproject.data.integration.HealthBodyWeightReader import com.devil.phoenixproject.data.integration.HealthBodyWeightSyncManager import com.devil.phoenixproject.data.integration.HealthIntegrationBodyWeightReader import com.devil.phoenixproject.data.integration.IntegrationManager +import com.devil.phoenixproject.data.migration.RequiredMigrationGate import com.devil.phoenixproject.data.repository.* import com.devil.phoenixproject.data.sync.PortalApiClient +import com.devil.phoenixproject.data.sync.ProfilePreferenceSyncCodec +import com.devil.phoenixproject.data.sync.ProfilePreferenceSyncRepository +import com.devil.phoenixproject.data.sync.SqlDelightProfilePreferenceSyncRepository import com.devil.phoenixproject.data.sync.PortalTokenStorage import com.devil.phoenixproject.data.sync.SupabaseConfig import com.devil.phoenixproject.data.sync.SyncManager @@ -22,7 +26,12 @@ val syncModule = module { ) } single { SqlDelightSyncRepository(get(), get()) } + single { ProfilePreferenceSyncCodec() } + single { + SqlDelightProfilePreferenceSyncRepository(database = get(), codec = get()) + } single { + val migrationManager = get() SyncManager( apiClient = get(), tokenStorage = get(), @@ -30,12 +39,25 @@ val syncModule = module { gamificationRepository = get(), repMetricRepository = get(), userProfileRepository = get(), + profilePreferenceSyncRepository = get(), externalActivityRepository = get(), velocityOneRepMaxRepository = get(), + isProfilePreferenceMigrationReady = { + migrationManager.requiredMigrationState.value is + com.devil.phoenixproject.data.migration.RequiredMigrationState.Ready + }, ) } single { HealthIntegrationBodyWeightReader(get()) } - single { HealthBodyWeightSyncManager(get(), get(), get(), get(), get()) } + single { + HealthBodyWeightSyncManager( + bodyWeightReader = get(), + externalActivityRepository = get(), + externalMeasurementRepository = get(), + requiredMigrationGate = get(), + userProfileRepository = get(), + ) + } single { SyncTriggerManager(get(), get(), get()) } single { IntegrationManager(get(), get(), get(), get(), get(), get(), get()) } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/assessment/AssessmentEngine.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/assessment/AssessmentEngine.kt index 596522433..5dbe46bd9 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/assessment/AssessmentEngine.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/assessment/AssessmentEngine.kt @@ -45,10 +45,10 @@ class AssessmentEngine { // Extrapolate load at 1RM velocity: load = (velocity - intercept) / slope // Clamp to at least 1 kg -- 1RM cannot be negative or zero - // Clamp to at most 110 kg -- MAX_WEIGHT_PER_CABLE_KG hardware ceiling (Trainer+) + // Assessment loads are total across both cables, so the hardware ceiling is 2 x per-cable max. val estimatedLoad = ((config.oneRmVelocityMs.toDouble() - intercept) / slope) .coerceAtLeast(1.0) - .coerceAtMost(Constants.MAX_WEIGHT_PER_CABLE_KG.toDouble()) + .coerceAtMost((Constants.MAX_WEIGHT_PER_CABLE_KG * 2f).toDouble()) // Compute R-squared val meanY = sumY / n diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/Models.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/Models.kt index 344c31b34..017635ac2 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/Models.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/Models.kt @@ -1,5 +1,7 @@ package com.devil.phoenixproject.domain.model +import kotlinx.serialization.Serializable + /** * Vitruvian Hardware Model */ @@ -321,6 +323,7 @@ enum class EccentricLoad(val percentage: Int, val displayName: String) { /** * Weight unit preference */ +@Serializable enum class WeightUnit { KG, LB, @@ -329,6 +332,7 @@ enum class WeightUnit { /** * Rep count timing mode - controls when the working rep number increments */ +@Serializable enum class RepCountTiming { TOP, // Count rep at concentric peak (top of lift) - what users expect BOTTOM, // Count rep at eccentric valley (bottom) - legacy machine behavior @@ -440,6 +444,7 @@ data class RepEvent( * the tier; the audio router handles the override, this enum only encodes the * vulgar intensity choice. */ +@Serializable enum class VulgarTier { MILD, STRONG, MIX } /** diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/ProfilePreferences.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/ProfilePreferences.kt new file mode 100644 index 000000000..3ab812ad0 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/ProfilePreferences.kt @@ -0,0 +1,128 @@ +package com.devil.phoenixproject.domain.model + +import kotlinx.serialization.Serializable + +@Serializable +data class CoreProfilePreferences( + val bodyWeightKg: Float = 0f, + val weightUnit: WeightUnit = WeightUnit.LB, + val weightIncrement: Float = -1f, +) + +@Serializable +data class RackPreferences( + val version: Int = 1, + val items: List = emptyList(), +) + +@Serializable +data class JustLiftDefaultsDocument( + val workoutModeId: Int = 0, + val weightPerCableKg: Float = 20f, + val weightChangePerRep: Float = 0f, + val eccentricLoadPercentage: Int = 100, + val echoLevelValue: Int = 1, + val stallDetectionEnabled: Boolean = true, + val repCountTimingName: String = "TOP", + val restSeconds: Int = 60, +) + +@Serializable +data class SingleExerciseDefaultsDocument( + val exerciseId: String, + val setReps: List, + val weightPerCableKg: Float, + val setWeightsPerCableKg: List, + val progressionKg: Float, + val setRestSeconds: List, + val workoutModeId: Int, + val eccentricLoadPercentage: Int, + val echoLevelValue: Int, + val duration: Int, + val isAMRAP: Boolean, + val perSetRestTime: Boolean, + val defaultRackItemIds: List = emptyList(), +) + +@Serializable +data class WorkoutPreferences( + val version: Int = 1, + val stopAtTop: Boolean = false, + val beepsEnabled: Boolean = true, + val stallDetectionEnabled: Boolean = true, + val audioRepCountEnabled: Boolean = false, + val repCountTiming: RepCountTiming = RepCountTiming.TOP, + val summaryCountdownSeconds: Int = 10, + val autoStartCountdownSeconds: Int = 5, + val gamificationEnabled: Boolean = true, + val autoStartRoutine: Boolean = false, + val countdownBeepsEnabled: Boolean = true, + val repSoundEnabled: Boolean = true, + val motionStartEnabled: Boolean = false, + val weightSuggestionsEnabled: Boolean = true, + val defaultRoutineExerciseUsePercentOfPR: Boolean = false, + val defaultRoutineExerciseWeightPercentOfPR: Int = 80, + val voiceStopEnabled: Boolean = false, + val justLiftDefaults: JustLiftDefaultsDocument = JustLiftDefaultsDocument(), + val singleExerciseDefaults: Map = emptyMap(), +) + +@Serializable +data class LedPreferences( + val version: Int = 1, + val colorScheme: Int = 0, + val discoModeUnlocked: Boolean = false, +) + +@Serializable +data class VbtPreferences( + val version: Int = 1, + val enabled: Boolean = true, + val velocityLossThresholdPercent: Int = 20, + val autoEndOnVelocityLoss: Boolean = false, + val defaultScalingBasis: ScalingBasis = ScalingBasis.MAX_WEIGHT_PR, + val verbalEncouragementEnabled: Boolean = false, + val vulgarModeEnabled: Boolean = false, + val vulgarTier: VulgarTier = VulgarTier.STRONG, + val dominatrixModeUnlocked: Boolean = false, + val dominatrixModeActive: Boolean = false, +) + +data class ProfileLocalSafetyPreferences( + val safeWord: String? = null, + val safeWordCalibrated: Boolean = false, + val adultsOnlyConfirmed: Boolean = false, + val adultsOnlyPrompted: Boolean = false, +) + +enum class ProfilePreferenceSectionName { CORE, RACK, WORKOUT, LED, VBT } + +sealed interface ProfilePreferenceValidity { + data object Valid : ProfilePreferenceValidity + data class Invalid(val reason: String) : ProfilePreferenceValidity +} + +data class ProfileSectionMetadata( + val updatedAt: Long, + val localGeneration: Long, + val serverRevision: Long, + val dirty: Boolean, +) + +data class ProfilePreferenceSection( + val value: T, + val raw: String? = null, + val validity: ProfilePreferenceValidity, + val metadata: ProfileSectionMetadata, +) + +data class UserProfilePreferences( + val profileId: String, + val schemaVersion: Int, + val legacyMigrationVersion: Int, + val core: ProfilePreferenceSection, + val rack: ProfilePreferenceSection, + val workout: ProfilePreferenceSection, + val led: ProfilePreferenceSection, + val vbt: ProfilePreferenceSection, +) diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/ScalingBasis.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/ScalingBasis.kt index 78faab05d..df9ae716a 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/ScalingBasis.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/ScalingBasis.kt @@ -1,6 +1,9 @@ package com.devil.phoenixproject.domain.model +import kotlinx.serialization.Serializable + /** Baseline a routine exercise's percentage weight scales from. */ +@Serializable enum class ScalingBasis { MAX_WEIGHT_PR, MAX_VOLUME_PR, diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/UserPreferences.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/UserPreferences.kt index e0b4f5d2a..547a18d6a 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/UserPreferences.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/UserPreferences.kt @@ -42,6 +42,7 @@ data class UserPreferences( // Issue #293: Custom backup destination (default = platform default location) val backupDestination: BackupDestination = BackupDestination.Default, // Issue #313: Velocity-Based Training (VBT) power loss threshold + val vbtEnabled: Boolean = true, val velocityLossThresholdPercent: Int = 20, val autoEndOnVelocityLoss: Boolean = false, // Issue #424: Suggestion-only next-set weight recommendations diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCase.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCase.kt new file mode 100644 index 000000000..8f01c4424 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCase.kt @@ -0,0 +1,94 @@ +package com.devil.phoenixproject.domain.usecase + +import com.devil.phoenixproject.data.repository.AssessmentRepository +import com.devil.phoenixproject.data.repository.MAX_RECENT_EXERCISE_SESSIONS +import com.devil.phoenixproject.data.repository.VelocityOneRepMaxRepository +import com.devil.phoenixproject.data.repository.WorkoutRepository +import com.devil.phoenixproject.domain.model.WorkoutSession +import com.devil.phoenixproject.util.OneRepMaxCalculator + +enum class CurrentOneRepMaxSource { + VELOCITY, + ASSESSMENT, + SESSION, +} + +data class CurrentOneRepMax( + val perCableKg: Float, + val source: CurrentOneRepMaxSource, + val measuredAt: Long, +) + +fun WorkoutSession.estimatedOneRepMaxPerCableOrNull(): Float? { + val load = weightPerCableKg.takeIf { it.isFinite() && it > 0f } ?: return null + val reps = workingReps.takeIf { it > 0 } + ?: totalReps.takeIf { it > 0 } + ?: return null + return OneRepMaxCalculator.estimate(load, reps) + .takeIf { it.isFinite() && it > 0f } +} + +private fun Float.validPositiveOrNull(): Float? = + takeIf { it.isFinite() && it > 0f } + +class ResolveCurrentOneRepMaxUseCase( + private val velocityRepository: VelocityOneRepMaxRepository, + private val assessmentRepository: AssessmentRepository, + private val workoutRepository: WorkoutRepository, +) { + suspend operator fun invoke(exerciseId: String, profileId: String): CurrentOneRepMax? { + require(exerciseId.isNotBlank()) + require(profileId.isNotBlank()) + + velocityRepository.getLatestPassing(exerciseId, profileId) + ?.takeIf { + it.exerciseId == exerciseId && + it.profileId == profileId && + it.passedQualityGate + } + ?.let { estimate -> + estimate.estimatedPerCableKg.validPositiveOrNull() + ?.let { estimate to it } + } + ?.let { + return CurrentOneRepMax( + perCableKg = it.second, + source = CurrentOneRepMaxSource.VELOCITY, + measuredAt = it.first.computedAt, + ) + } + + assessmentRepository.getLatestAssessment(exerciseId, profileId)?.let { assessment -> + if (assessment.exerciseId == exerciseId && assessment.profileId == profileId) { + val validTotalKg = assessment.userOverrideKg?.validPositiveOrNull() + ?: assessment.estimatedOneRepMaxKg.validPositiveOrNull() + val perCableKg = validTotalKg?.div(2f)?.validPositiveOrNull() + if (perCableKg != null) { + return CurrentOneRepMax( + perCableKg = perCableKg, + source = CurrentOneRepMaxSource.ASSESSMENT, + measuredAt = assessment.createdAt, + ) + } + } + } + + workoutRepository.getRecentCompletedSessionsForExercise( + exerciseId = exerciseId, + profileId = profileId, + limit = MAX_RECENT_EXERCISE_SESSIONS, + ).forEach { session -> + if (session.exerciseId == exerciseId && session.profileId == profileId) { + val estimate = session.estimatedOneRepMaxPerCableOrNull() + if (estimate != null) { + return CurrentOneRepMax( + perCableKg = estimate, + source = CurrentOneRepMaxSource.SESSION, + measuredAt = session.timestamp, + ) + } + } + } + return null + } +} diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/voice/SafeWordDetectionManager.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/voice/SafeWordDetectionManager.kt index 64fb72764..1ee3f4394 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/voice/SafeWordDetectionManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/voice/SafeWordDetectionManager.kt @@ -1,7 +1,8 @@ package com.devil.phoenixproject.domain.voice import co.touchlab.kermit.Logger -import com.devil.phoenixproject.data.preferences.PreferencesManager +import com.devil.phoenixproject.data.repository.ActiveProfileContext +import com.devil.phoenixproject.data.repository.UserProfileRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -24,7 +25,10 @@ import kotlinx.coroutines.launch * * Issue #141: Voice-activated emergency stop. */ -class SafeWordDetectionManager(private val preferencesManager: PreferencesManager, private val listenerFactory: SafeWordListenerFactory) { +class SafeWordDetectionManager( + private val userProfileRepository: UserProfileRepository, + private val listenerFactory: SafeWordListenerFactory, +) { private companion object { const val TAG = "SafeWordDetectionManager" } @@ -52,17 +56,21 @@ class SafeWordDetectionManager(private val preferencesManager: PreferencesManage * No-op if voice stop is disabled or no safe word is configured. */ fun startForWorkout() { - val prefs = preferencesManager.preferencesFlow.value - if (!prefs.voiceStopEnabled) { + val context = userProfileRepository.activeProfileContext.value as? ActiveProfileContext.Ready + if (context == null) { + Logger.w(TAG) { "Profile context is switching, skipping voice stop" } + return + } + if (!context.preferences.workout.value.voiceStopEnabled) { Logger.d(TAG) { "Voice stop not enabled, skipping" } return } - val safeWord = prefs.safeWord + val safeWord = context.localSafety.safeWord if (safeWord.isNullOrBlank()) { Logger.w(TAG) { "Voice stop enabled but no safe word configured, skipping" } return } - if (!prefs.safeWordCalibrated) { + if (!context.localSafety.safeWordCalibrated) { Logger.w(TAG) { "Voice stop enabled but safe word not calibrated, skipping" } return } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/AdultModePresentation.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/AdultModePresentation.kt similarity index 82% rename from shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/AdultModePresentation.kt rename to shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/AdultModePresentation.kt index 628369f5f..dfa5341bd 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/AdultModePresentation.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/AdultModePresentation.kt @@ -1,4 +1,4 @@ -package com.devil.phoenixproject.presentation.screen +package com.devil.phoenixproject.presentation.components /** * Presentation policy for the adult-mode dialogs. This stays Compose-free so the @@ -32,11 +32,6 @@ data class AdultModePresentation( usesBespokePinkAccent = false, ) - fun shouldShowDominatrixHint( - verbalEncouragementEnabled: Boolean, - vulgarModeEnabled: Boolean, - dominatrixModeUnlocked: Boolean, - ): Boolean = verbalEncouragementEnabled && vulgarModeEnabled && !dominatrixModeUnlocked } } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/DeleteProfileDialog.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/DeleteProfileDialog.kt deleted file mode 100644 index ca4f7e3ad..000000000 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/DeleteProfileDialog.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.devil.phoenixproject.presentation.components - -import androidx.compose.runtime.Composable -import co.touchlab.kermit.Logger -import com.devil.phoenixproject.data.repository.UserProfile -import com.devil.phoenixproject.data.repository.UserProfileRepository -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import org.jetbrains.compose.resources.stringResource -import vitruvianprojectphoenix.shared.generated.resources.Res -import vitruvianprojectphoenix.shared.generated.resources.action_delete -import vitruvianprojectphoenix.shared.generated.resources.delete_profile -import vitruvianprojectphoenix.shared.generated.resources.delete_profile_message - -/** - * Confirmation dialog for deleting a user profile. - */ -@Composable -fun DeleteProfileDialog(profile: UserProfile, profileRepository: UserProfileRepository, scope: CoroutineScope, onDismiss: () -> Unit) { - DestructiveConfirmDialog( - title = stringResource(Res.string.delete_profile), - message = stringResource(Res.string.delete_profile_message, profile.name), - confirmText = stringResource(Res.string.action_delete), - onConfirm = { - scope.launch { - // Issue #393: Unhandled exceptions in profile deletion caused - // SIGABRT on iOS (Kotlin/Native abort() on uncaught exception). - try { - val deleted = profileRepository.deleteProfile(profile.id) - if (deleted) { - onDismiss() - } else { - // Protected profile (e.g. "default") — keep dialog open - Logger.w { "PROFILE_DELETE: Refused to delete protected profile '${profile.name}' (id=${profile.id})" } - } - } catch (e: kotlin.coroutines.cancellation.CancellationException) { - throw e - } catch (e: Exception) { - Logger.e(e) { "PROFILE_DELETE: Failed to delete profile '${profile.name}' (id=${profile.id})" } - } - } - }, - onDismiss = onDismiss, - ) -} diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/EditProfileDialog.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/EditProfileDialog.kt deleted file mode 100644 index 8c6d890d6..000000000 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/EditProfileDialog.kt +++ /dev/null @@ -1,140 +0,0 @@ -package com.devil.phoenixproject.presentation.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.selection.selectableGroup -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.role -import androidx.compose.ui.semantics.selected -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.unit.dp -import com.devil.phoenixproject.data.repository.UserProfile -import com.devil.phoenixproject.data.repository.UserProfileRepository -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import org.jetbrains.compose.resources.stringResource -import vitruvianprojectphoenix.shared.generated.resources.* -import vitruvianprojectphoenix.shared.generated.resources.Res - -/** - * Dialog for editing an existing user profile. - * Allows changing name and color. - */ -@Composable -fun EditProfileDialog(profile: UserProfile, profileRepository: UserProfileRepository, scope: CoroutineScope, onDismiss: () -> Unit) { - var name by remember { mutableStateOf(profile.name) } - var selectedColorIndex by remember { mutableStateOf(profile.colorIndex) } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(Res.string.edit_profile)) }, - text = { - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - OutlinedTextField( - value = name, - onValueChange = { name = it }, - label = { Text(stringResource(Res.string.label_name)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - - Text( - "Color", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - val colorNames = listOf( - stringResource(Res.string.color_blue), - stringResource(Res.string.color_green), - stringResource(Res.string.color_amber), - stringResource(Res.string.color_red), - stringResource(Res.string.color_purple), - stringResource(Res.string.color_pink), - stringResource(Res.string.color_cyan), - stringResource(Res.string.color_orange), - ) - val selectColorTemplate = stringResource(Res.string.cd_select_profile_color) - - Row( - modifier = Modifier.fillMaxWidth().selectableGroup(), - horizontalArrangement = Arrangement.SpaceEvenly, - ) { - ProfileColors.forEachIndexed { index, color -> - val colorName = colorNames[index] - val colorDesc = selectColorTemplate.replace("%1\$s", colorName) - val isThisSelected = index == selectedColorIndex - Box( - modifier = Modifier - .size(36.dp) - .clip(CircleShape) - .background(color) - .then( - if (isThisSelected) { - Modifier.border( - 3.dp, - MaterialTheme.colorScheme.primary, - CircleShape, - ) - } else { - Modifier - }, - ) - .clickable { selectedColorIndex = index } - .semantics { - role = Role.RadioButton - selected = isThisSelected - contentDescription = colorDesc - }, - contentAlignment = Alignment.Center, - ) { - if (isThisSelected) { - Box( - modifier = Modifier - .size(12.dp) - .background(Color.White, CircleShape), - ) - } - } - } - } - } - }, - confirmButton = { - TextButton( - onClick = { - if (name.isNotBlank()) { - scope.launch { - profileRepository.updateProfile( - profile.id, - name.trim(), - selectedColorIndex, - ) - } - onDismiss() - } - }, - enabled = name.isNotBlank(), - ) { - Text(stringResource(Res.string.action_save)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(Res.string.action_cancel)) - } - }, - ) -} diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileDialogs.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileDialogs.kt new file mode 100644 index 000000000..9cc18114b --- /dev/null +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileDialogs.kt @@ -0,0 +1,341 @@ +package com.devil.phoenixproject.presentation.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.devil.phoenixproject.data.repository.UserProfile +import org.jetbrains.compose.resources.stringResource +import vitruvianprojectphoenix.shared.generated.resources.Res +import vitruvianprojectphoenix.shared.generated.resources.action_add +import vitruvianprojectphoenix.shared.generated.resources.action_cancel +import vitruvianprojectphoenix.shared.generated.resources.action_delete +import vitruvianprojectphoenix.shared.generated.resources.action_save +import vitruvianprojectphoenix.shared.generated.resources.action_retry +import vitruvianprojectphoenix.shared.generated.resources.add_profile +import vitruvianprojectphoenix.shared.generated.resources.cd_select_profile_color +import vitruvianprojectphoenix.shared.generated.resources.choose_color +import vitruvianprojectphoenix.shared.generated.resources.color_amber +import vitruvianprojectphoenix.shared.generated.resources.color_blue +import vitruvianprojectphoenix.shared.generated.resources.color_cyan +import vitruvianprojectphoenix.shared.generated.resources.color_green +import vitruvianprojectphoenix.shared.generated.resources.color_orange +import vitruvianprojectphoenix.shared.generated.resources.color_pink +import vitruvianprojectphoenix.shared.generated.resources.color_purple +import vitruvianprojectphoenix.shared.generated.resources.color_red +import vitruvianprojectphoenix.shared.generated.resources.delete_profile +import vitruvianprojectphoenix.shared.generated.resources.edit_profile +import vitruvianprojectphoenix.shared.generated.resources.label_name +import vitruvianprojectphoenix.shared.generated.resources.profile_delete_reassign_message +import vitruvianprojectphoenix.shared.generated.resources.profile_recovery_message +import vitruvianprojectphoenix.shared.generated.resources.profile_recovery_title + +@Composable +fun ProfileAddDialog( + existingProfileCount: Int, + isSubmitting: Boolean, + errorMessage: String?, + onConfirm: (name: String, colorIndex: Int) -> Unit, + onDismiss: () -> Unit, +) { + var name by remember { mutableStateOf("") } + var selectedColorIndex by remember(existingProfileCount) { + mutableIntStateOf(suggestedProfileColorIndex(existingProfileCount)) + } + + ProfileIdentityDialog( + title = stringResource(Res.string.add_profile), + name = name, + selectedColorIndex = selectedColorIndex, + isSubmitting = isSubmitting, + errorMessage = errorMessage, + confirmLabel = stringResource(Res.string.action_add), + onNameChange = { name = it }, + onColorSelected = { selectedColorIndex = it }, + onConfirm = { + onConfirm(name.trim(), normalizedProfileColorIndex(selectedColorIndex)) + }, + onDismiss = onDismiss, + ) +} + +@Composable +fun ProfileEditDialog( + profile: UserProfile, + isSubmitting: Boolean, + onConfirm: (name: String, colorIndex: Int) -> Unit, + onDismiss: () -> Unit, +) { + var name by remember(profile.id) { mutableStateOf(profile.name) } + var selectedColorIndex by remember(profile.id) { + mutableIntStateOf(normalizedProfileColorIndex(profile.colorIndex)) + } + + ProfileIdentityDialog( + title = stringResource(Res.string.edit_profile), + name = name, + selectedColorIndex = selectedColorIndex, + isSubmitting = isSubmitting, + errorMessage = null, + confirmLabel = stringResource(Res.string.action_save), + onNameChange = { name = it }, + onColorSelected = { selectedColorIndex = it }, + onConfirm = { + onConfirm(name.trim(), normalizedProfileColorIndex(selectedColorIndex)) + }, + onDismiss = onDismiss, + ) +} + +@Composable +fun ProfileDeleteDialog( + profile: UserProfile, + isSubmitting: Boolean, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + require(canDeleteProfile(profile)) { + "Only the active non-default profile may be deleted from Profile" + } + + AlertDialog( + onDismissRequest = { + if (!isSubmitting) onDismiss() + }, + title = { Text(stringResource(Res.string.delete_profile)) }, + text = { + Text( + text = stringResource( + Res.string.profile_delete_reassign_message, + profile.name, + ), + ) + }, + confirmButton = { + TextButton( + onClick = onConfirm, + enabled = !isSubmitting, + ) { + Text(stringResource(Res.string.action_delete)) + } + }, + dismissButton = { + TextButton( + onClick = onDismiss, + enabled = !isSubmitting, + ) { + Text(stringResource(Res.string.action_cancel)) + } + }, + ) +} + +@Composable +fun ProfileRecoveryDialog( + isRetrying: Boolean, + errorMessage: String?, + onRetry: () -> Unit, +) { + AlertDialog( + onDismissRequest = {}, + title = { Text(stringResource(Res.string.profile_recovery_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(stringResource(Res.string.profile_recovery_message)) + errorMessage?.let { + Text( + text = it, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.semantics { + liveRegion = LiveRegionMode.Polite + }, + ) + } + } + }, + confirmButton = { + Button(onClick = onRetry, enabled = !isRetrying) { + Text(stringResource(Res.string.action_retry)) + } + }, + ) +} + +@Composable +private fun ProfileIdentityDialog( + title: String, + name: String, + selectedColorIndex: Int, + isSubmitting: Boolean, + errorMessage: String?, + confirmLabel: String, + onNameChange: (String) -> Unit, + onColorSelected: (Int) -> Unit, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + val trimmedName = name.trim() + AlertDialog( + onDismissRequest = { + if (!isSubmitting) onDismiss() + }, + title = { Text(title) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + OutlinedTextField( + value = name, + onValueChange = onNameChange, + enabled = !isSubmitting, + label = { Text(stringResource(Res.string.label_name)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + errorMessage?.let { message -> + Text( + text = message, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.semantics { + liveRegion = LiveRegionMode.Polite + }, + ) + } + Text( + text = stringResource(Res.string.choose_color), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + ProfileColorSelector( + selectedColorIndex = selectedColorIndex, + enabled = !isSubmitting, + onColorSelected = onColorSelected, + ) + } + }, + confirmButton = { + TextButton( + onClick = onConfirm, + enabled = trimmedName.isNotEmpty() && !isSubmitting, + ) { + Text(confirmLabel) + } + }, + dismissButton = { + TextButton( + onClick = onDismiss, + enabled = !isSubmitting, + ) { + Text(stringResource(Res.string.action_cancel)) + } + }, + ) +} + +@Composable +private fun ProfileColorSelector( + selectedColorIndex: Int, + enabled: Boolean, + onColorSelected: (Int) -> Unit, +) { + val colorNames = listOf( + stringResource(Res.string.color_blue), + stringResource(Res.string.color_green), + stringResource(Res.string.color_amber), + stringResource(Res.string.color_red), + stringResource(Res.string.color_purple), + stringResource(Res.string.color_pink), + stringResource(Res.string.color_cyan), + stringResource(Res.string.color_orange), + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .selectableGroup(), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + ProfileColors.indices.chunked(4).forEach { indices -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + indices.forEach { index -> + val color = ProfileColors[index] + val selected = normalizedProfileColorIndex(selectedColorIndex) == index + val description = stringResource( + Res.string.cd_select_profile_color, + colorNames[index], + ) + Box( + modifier = Modifier + .size(48.dp) + .selectable( + selected = selected, + enabled = enabled, + role = Role.RadioButton, + onClick = { onColorSelected(index) }, + ) + .semantics { contentDescription = description }, + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .size(36.dp) + .background(color, CircleShape) + .then( + if (selected) { + Modifier.border( + width = 2.dp, + color = profileInitialsColor(color), + shape = CircleShape, + ) + } else { + Modifier + }, + ), + contentAlignment = Alignment.Center, + ) { + if (selected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = profileInitialsColor(color), + modifier = Modifier.size(20.dp), + ) + } + } + } + } + } + } + } +} diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileExerciseInsights.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileExerciseInsights.kt new file mode 100644 index 000000000..424448f02 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileExerciseInsights.kt @@ -0,0 +1,362 @@ +package com.devil.phoenixproject.presentation.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Card +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.devil.phoenixproject.domain.model.Exercise +import com.devil.phoenixproject.domain.model.WeightUnit +import com.devil.phoenixproject.domain.model.WorkoutSession +import com.devil.phoenixproject.domain.model.effectiveTotalVolumeKg +import com.devil.phoenixproject.domain.usecase.CurrentOneRepMax +import com.devil.phoenixproject.domain.usecase.CurrentOneRepMaxSource +import com.devil.phoenixproject.presentation.util.WeightDisplayFormatter +import com.devil.phoenixproject.presentation.viewmodel.ProfileLoadable +import com.devil.phoenixproject.presentation.viewmodel.ProfilePrHighlights +import com.devil.phoenixproject.util.KmpUtils +import com.devil.phoenixproject.util.UnitConverter +import org.jetbrains.compose.resources.stringResource +import vitruvianprojectphoenix.shared.generated.resources.* + +data class ProfileRecentHistory( + val sessionsNewestFirst: List, + val chartPointsOldestFirst: List, +) + +internal fun buildProfileRecentHistory( + sessions: List, + labelForTimestamp: (Long) -> String, +): ProfileRecentHistory { + val sessionsNewestFirst = sessions + .sortedWith( + compareByDescending { it.timestamp } + .thenByDescending { it.id }, + ) + .take(5) + val chartPointsOldestFirst = sessionsNewestFirst + .asReversed() + .mapNotNull { session -> + session.effectiveTotalVolumeKg() + .takeIf { it.isFinite() && it > 0f } + ?.let { volume -> + VolumePoint( + label = labelForTimestamp(session.timestamp), + volume = volume, + ) + } + } + return ProfileRecentHistory(sessionsNewestFirst, chartPointsOldestFirst) +} + +@Composable +fun ProfileExerciseInsights( + selectedExercise: Exercise?, + missingExerciseId: String?, + selectionFailure: Throwable?, + currentOneRepMax: ProfileLoadable, + prHighlights: ProfileLoadable, + recentSessions: ProfileLoadable>, + weightUnit: WeightUnit, + onChooseExercise: () -> Unit, + onViewFullHistory: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val chooseLabel = stringResource(Res.string.profile_choose_exercise) + val exerciseLabel = selectedExercise?.displayName ?: chooseLabel + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(Res.string.profile_exercise_insights), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + Surface( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .clickable(role = Role.Button, onClick = onChooseExercise) + .clearAndSetSemantics { + contentDescription = exerciseLabel + role = Role.Button + onClick(label = exerciseLabel) { + onChooseExercise() + true + } + }, + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = exerciseLabel, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Icon( + imageVector = Icons.Default.Search, + contentDescription = null, + ) + } + } + + if (selectedExercise == null) { + val message = when { + selectionFailure != null -> Res.string.profile_insights_load_failed + missingExerciseId != null -> Res.string.profile_missing_exercise + else -> Res.string.profile_no_exercise_history + } + Text( + text = stringResource(message), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 8.dp), + ) + } else { + CurrentOneRepMaxCard(currentOneRepMax, weightUnit) + ProfilePrHighlightsCard(prHighlights, weightUnit) + ProfileRecentHistoryCard( + recentSessions = recentSessions, + selectedExerciseId = selectedExercise.id.orEmpty(), + weightUnit = weightUnit, + onViewFullHistory = onViewFullHistory, + ) + } + } +} + +@Composable +private fun CurrentOneRepMaxCard( + loadable: ProfileLoadable, + weightUnit: WeightUnit, +) { + InsightCard(title = stringResource(Res.string.profile_current_one_rep_max)) { + when (loadable) { + ProfileLoadable.Empty -> Text( + stringResource(Res.string.profile_one_rep_max_source_none), + ) + ProfileLoadable.Loading -> LoadingIndicator(LoadingIndicatorSize.Medium) + is ProfileLoadable.Failed -> InsightFailure() + is ProfileLoadable.Ready -> { + Text( + text = weightValue(loadable.value.perCableKg, weightUnit), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + Text( + text = stringResource( + when (loadable.value.source) { + CurrentOneRepMaxSource.VELOCITY -> + Res.string.profile_one_rep_max_source_velocity + CurrentOneRepMaxSource.ASSESSMENT -> + Res.string.profile_one_rep_max_source_assessment + CurrentOneRepMaxSource.SESSION -> + Res.string.profile_one_rep_max_source_session + }, + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +fun ProfilePrHighlightsCard( + loadable: ProfileLoadable, + weightUnit: WeightUnit, +) { + InsightCard(title = stringResource(Res.string.profile_pr_highlights)) { + when (loadable) { + ProfileLoadable.Empty -> PrHighlightsRow(ProfilePrHighlights(null, null, null), weightUnit) + ProfileLoadable.Loading -> LoadingIndicator(LoadingIndicatorSize.Medium) + is ProfileLoadable.Failed -> InsightFailure() + is ProfileLoadable.Ready -> PrHighlightsRow(loadable.value, weightUnit) + } + } +} + +@Composable +private fun PrHighlightsRow(highlights: ProfilePrHighlights, weightUnit: WeightUnit) { + val repsLabel = stringResource(Res.string.label_reps) + val perCableLabel = stringResource(Res.string.label_per_cable) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + PrCell( + label = stringResource(Res.string.profile_pr_max_weight), + value = highlights.maxWeightPerCableKg?.let { weightValue(it, weightUnit) } ?: "—", + modifier = Modifier.weight(1f), + ) + PrCell( + label = stringResource(Res.string.profile_pr_estimated_one_rep_max), + value = highlights.estimatedOneRepMaxPerCableKg + ?.let { weightValue(it, weightUnit) } + ?: "—", + modifier = Modifier.weight(1f), + ) + // maxVolumeKg is a legacy-named per-cable kg x reps value; never multiply cable count. + val maxVolume = highlights.maxVolumeKg?.let { + "${WeightDisplayFormatter.formatPerCableWeight(it, weightUnit)} " + + "${unitSuffix(weightUnit)} × $repsLabel · $perCableLabel" + } ?: "—" + PrCell( + label = stringResource(Res.string.profile_pr_max_volume), + value = maxVolume, + modifier = Modifier.weight(1f), + ) + } +} + +@Composable +private fun PrCell(label: String, value: String, modifier: Modifier = Modifier) { + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + } +} + +@Composable +fun ProfileRecentHistoryCard( + recentSessions: ProfileLoadable>, + selectedExerciseId: String, + weightUnit: WeightUnit, + onViewFullHistory: (String) -> Unit, +) { + InsightCard(title = stringResource(Res.string.profile_recent_history)) { + when (recentSessions) { + ProfileLoadable.Empty -> Text(stringResource(Res.string.profile_no_exercise_history)) + ProfileLoadable.Loading -> LoadingIndicator(LoadingIndicatorSize.Medium) + is ProfileLoadable.Failed -> InsightFailure() + is ProfileLoadable.Ready -> { + val repsLabel = stringResource(Res.string.label_reps) + val history = buildProfileRecentHistory(recentSessions.value) { timestamp -> + KmpUtils.formatTimestamp(timestamp, "MMM d") + } + if (history.sessionsNewestFirst.isEmpty()) { + Text(stringResource(Res.string.profile_no_exercise_history)) + } else { + if (history.chartPointsOldestFirst.isNotEmpty()) { + VolumeHistoryChart( + data = history.chartPointsOldestFirst, + modifier = Modifier + .fillMaxWidth() + .height(112.dp) + .clearAndSetSemantics { }, + ) + } + history.sessionsNewestFirst.forEachIndexed { index, session -> + if (index > 0) HorizontalDivider() + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = KmpUtils.formatTimestamp(session.timestamp, "MMM d"), + style = MaterialTheme.typography.bodySmall, + ) + val volume = session.effectiveTotalVolumeKg() + Text( + text = if (volume.isFinite() && volume > 0f) { + "${formatTotalVolume(volume, weightUnit)} " + + "${unitSuffix(weightUnit)} × $repsLabel" + } else { + "—" + }, + style = MaterialTheme.typography.bodySmall, + ) + } + } + if (selectedExerciseId.isNotBlank()) { + TextButton(onClick = { onViewFullHistory(selectedExerciseId) }) { + Text(stringResource(Res.string.profile_view_full_history)) + } + } + } + } + } + } +} + +@Composable +private fun InsightCard(title: String, content: @Composable ColumnScope.() -> Unit) { + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + content() + } + } +} + +@Composable +private fun InsightFailure() { + Text( + text = stringResource(Res.string.profile_insights_load_failed), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + ) +} + +@Composable +private fun weightValue(perCableKg: Float, weightUnit: WeightUnit): String = + "${WeightDisplayFormatter.formatPerCableWeight(perCableKg, weightUnit)} ${unitSuffix(weightUnit)}" + +private fun formatTotalVolume(totalVolumeKg: Float, weightUnit: WeightUnit): String = + UnitConverter.formatDecimal( + when (weightUnit) { + WeightUnit.KG -> totalVolumeKg + WeightUnit.LB -> UnitConverter.kgToLb(totalVolumeKg) + }, + ) + +@Composable +private fun unitSuffix(weightUnit: WeightUnit): String = when (weightUnit) { + WeightUnit.KG -> stringResource(Res.string.label_kg) + WeightUnit.LB -> stringResource(Res.string.label_lbs) +} diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileIdentityComponents.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileIdentityComponents.kt new file mode 100644 index 000000000..e25d3719c --- /dev/null +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileIdentityComponents.kt @@ -0,0 +1,97 @@ +package com.devil.phoenixproject.presentation.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.devil.phoenixproject.data.repository.UserProfile + +val ProfileColors = listOf( + Color(0xFF3B82F6), + Color(0xFF10B981), + Color(0xFFF59E0B), + Color(0xFFEF4444), + Color(0xFF8B5CF6), + Color(0xFFEC4899), + Color(0xFF06B6D4), + Color(0xFFF97316), +) + +const val PROFILE_COLOR_COUNT = 8 + +internal fun suggestedProfileColorIndex(profileCount: Int): Int = + profileCount.coerceAtLeast(0) % PROFILE_COLOR_COUNT + +internal fun normalizedProfileColorIndex(colorIndex: Int): Int = + colorIndex.takeIf(ProfileColors.indices::contains) ?: 0 + +internal fun canDeleteProfile(profile: UserProfile): Boolean = + profile.id != "default" && profile.isActive + +internal fun profileInitialsColor(background: Color): Color { + val luminance = background.luminance() + val blackContrast = (luminance + 0.05f) / 0.05f + val whiteContrast = 1.05f / (luminance + 0.05f) + return if (blackContrast >= whiteContrast) Color.Black else Color.White +} + +@Composable +fun ProfileAvatar( + profile: UserProfile, + isActive: Boolean = false, + onClick: (() -> Unit)? = null, + modifier: Modifier = Modifier, + size: Dp = 40.dp, +) { + val accessibleName = profile.name.trim().ifEmpty { "?" } + val interactionModifier = if (onClick == null) { + Modifier + } else { + Modifier + .minimumInteractiveComponentSize() + .clickable( + role = Role.Button, + onClick = onClick, + ) + .semantics { contentDescription = accessibleName } + } + val background = ProfileColors[normalizedProfileColorIndex(profile.colorIndex)] + + Box( + modifier = modifier.then(interactionModifier), + contentAlignment = Alignment.Center, + ) { + Surface( + modifier = Modifier + .size(size) + .shadow(if (isActive) 8.dp else 0.dp, CircleShape) + .clearAndSetSemantics { }, + shape = CircleShape, + color = background, + ) { + Text( + text = accessibleName.take(1).uppercase(), + color = profileInitialsColor(background), + fontWeight = FontWeight.Bold, + modifier = Modifier.wrapContentSize(Alignment.Center), + ) + } + } +} diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileListItem.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileListItem.kt index dae36a5f7..500a490e8 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileListItem.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileListItem.kt @@ -1,9 +1,7 @@ package com.devil.phoenixproject.presentation.components -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.selection.selectable import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material3.* @@ -11,7 +9,13 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.devil.phoenixproject.data.repository.UserProfile import org.jetbrains.compose.resources.stringResource @@ -19,22 +23,34 @@ import vitruvianprojectphoenix.shared.generated.resources.* import vitruvianprojectphoenix.shared.generated.resources.Res /** - * Individual profile row for the side panel. + * Individual profile row for the shared switcher. * Shows avatar, name, and active indicator. - * Supports tap to select and long-press for context menu. */ -@OptIn(ExperimentalFoundationApi::class) @Composable -fun ProfileListItem(profile: UserProfile, isActive: Boolean, onClick: () -> Unit, onLongClick: () -> Unit, modifier: Modifier = Modifier) { - val profileColor = ProfileColors.getOrElse(profile.colorIndex) { ProfileColors[0] } - +fun ProfileListItem( + profile: UserProfile, + isActive: Boolean, + onClick: () -> Unit, + enabled: Boolean = true, + switching: Boolean = false, + modifier: Modifier = Modifier, +) { Surface( modifier = modifier .fillMaxWidth() - .combinedClickable( - onClick = onClick, - onLongClick = onLongClick, - ), + .heightIn(min = 56.dp) + .then( + Modifier.selectable( + selected = isActive, + enabled = enabled && !switching && !isActive, + role = Role.RadioButton, + onClick = onClick, + ), + ) + .semantics { + selected = isActive + role = Role.RadioButton + }, color = if (isActive) { MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) } else { @@ -48,21 +64,10 @@ fun ProfileListItem(profile: UserProfile, isActive: Boolean, onClick: () -> Unit verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - // Avatar - Surface( - modifier = Modifier.size(40.dp), - shape = CircleShape, - color = profileColor, - ) { - Box(contentAlignment = Alignment.Center) { - Text( - text = profile.name.take(1).uppercase(), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = Color.White, - ) - } - } + ProfileAvatar( + profile = profile, + isActive = isActive, + ) // Name Text( @@ -71,10 +76,20 @@ fun ProfileListItem(profile: UserProfile, isActive: Boolean, onClick: () -> Unit fontWeight = if (isActive) FontWeight.Bold else FontWeight.Normal, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) // Active indicator - if (isActive) { + if (switching) { + val switchingDescription = stringResource(Res.string.profile_switching) + CircularProgressIndicator( + modifier = Modifier + .size(20.dp) + .semantics { contentDescription = switchingDescription }, + strokeWidth = 2.dp, + ) + } else if (isActive) { Icon( imageVector = Icons.Default.Check, contentDescription = stringResource(Res.string.cd_active), diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfilePreferenceComponents.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfilePreferenceComponents.kt new file mode 100644 index 000000000..dd1d4ec2a --- /dev/null +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfilePreferenceComponents.kt @@ -0,0 +1,971 @@ +package com.devil.phoenixproject.presentation.components + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.FitnessCenter +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Slider +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.devil.phoenixproject.domain.model.CoreProfilePreferences +import com.devil.phoenixproject.domain.model.LedPreferences +import com.devil.phoenixproject.domain.model.ProfileLocalSafetyPreferences +import com.devil.phoenixproject.domain.model.ScalingBasis +import com.devil.phoenixproject.domain.model.UserProfilePreferences +import com.devil.phoenixproject.domain.model.VbtPreferences +import com.devil.phoenixproject.domain.model.VulgarTier +import com.devil.phoenixproject.domain.model.WeightUnit +import com.devil.phoenixproject.domain.model.WorkoutPreferences +import com.devil.phoenixproject.presentation.viewmodel.ProfilePreferenceSection +import com.devil.phoenixproject.util.ColorSchemes +import com.devil.phoenixproject.util.Constants +import com.devil.phoenixproject.util.KmpUtils +import com.devil.phoenixproject.util.UnitConverter +import kotlin.math.abs +import kotlin.math.roundToInt +import org.jetbrains.compose.resources.stringResource +import vitruvianprojectphoenix.shared.generated.resources.* + +@Composable +fun ProfilePreferenceSections( + profileId: String, + preferences: UserProfilePreferences, + localSafety: ProfileLocalSafetyPreferences, + importedBodyWeightMeasuredAt: Long?, + busySections: Set, + isConnected: Boolean, + discoModeActive: Boolean, + onCoreChange: (CoreProfilePreferences) -> Long?, + onWorkoutChange: (WorkoutPreferences) -> Long?, + onLedChange: (LedPreferences) -> Long?, + onVbtChange: (VbtPreferences) -> Long?, + onLocalSafetyChange: (ProfileLocalSafetyPreferences) -> Long?, + onRequestAdultsOnlyConfirmation: () -> Unit, + onUnlockDiscoMode: () -> Long?, + onUnlockDominatrixMode: () -> Long?, + onManageEquipmentRack: () -> Unit, + onDiscoModeToggle: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + val core = preferences.core.value + val rack = preferences.rack.value + val workout = preferences.workout.value + val led = preferences.led.value + val vbt = preferences.vbt.value + + key(profileId) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + MeasurementsPreferenceCard( + profileId = profileId, + core = core, + importedBodyWeightMeasuredAt = importedBodyWeightMeasuredAt, + enabled = ProfilePreferenceSection.CORE !in busySections, + onCoreChange = onCoreChange, + ) + PreferenceCard(title = stringResource(Res.string.equipment_rack_title)) { + val enabledItems = rack.items.count { it.enabled } + Row( + modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp) + .clickable(onClick = onManageEquipmentRack) + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Default.FitnessCenter, + contentDescription = stringResource(Res.string.cd_equipment_rack), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text(stringResource(Res.string.equipment_rack_manage)) + Text( + text = "$enabledItems/${rack.items.size}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon(Icons.Default.ChevronRight, contentDescription = null) + } + } + WorkoutPreferenceCard( + workout = workout, + busySections = busySections, + onWorkoutChange = onWorkoutChange, + ) + LedPreferenceCard( + profileId = profileId, + led = led, + busySections = busySections, + isConnected = isConnected, + discoModeActive = discoModeActive, + onLedChange = onLedChange, + onUnlockDiscoMode = onUnlockDiscoMode, + onDiscoModeToggle = onDiscoModeToggle, + ) + VbtPreferenceCard( + profileId = profileId, + vbt = vbt, + localSafety = localSafety, + busySections = busySections, + onVbtChange = onVbtChange, + onRequestAdultsOnlyConfirmation = onRequestAdultsOnlyConfirmation, + onUnlockDominatrixMode = onUnlockDominatrixMode, + ) + SafetyPreferenceCard( + profileId = profileId, + localSafety = localSafety, + voiceStopEnabled = workout.voiceStopEnabled, + busySections = busySections, + onLocalSafetyChange = onLocalSafetyChange, + ) + } + } +} + +@Composable +@OptIn(ExperimentalLayoutApi::class) +private fun MeasurementsPreferenceCard( + profileId: String, + core: CoreProfilePreferences, + importedBodyWeightMeasuredAt: Long?, + enabled: Boolean, + onCoreChange: (CoreProfilePreferences) -> Long?, +) { + val authoritativeBodyWeight = core.bodyWeightKg + var bodyWeightDraft by rememberSaveable(profileId, core.weightUnit, authoritativeBodyWeight) { + mutableStateOf(displayBodyWeight(authoritativeBodyWeight, core.weightUnit)) + } + var bodyWeightDraftEdited by rememberSaveable( + profileId, + core.weightUnit, + authoritativeBodyWeight, + ) { + mutableStateOf(false) + } + LaunchedEffect(profileId, core.weightUnit, authoritativeBodyWeight) { + bodyWeightDraft = displayBodyWeight(authoritativeBodyWeight, core.weightUnit) + bodyWeightDraftEdited = false + } + val bodyWeightKgToSave = bodyWeightKgForSave( + authoritativeBodyWeightKg = authoritativeBodyWeight, + draft = bodyWeightDraft, + weightUnit = core.weightUnit, + draftEdited = bodyWeightDraftEdited, + ) + val bodyWeightInvalid = bodyWeightKgToSave == null + + PreferenceCard(title = stringResource(Res.string.profile_measurements)) { + Text( + text = stringResource(Res.string.settings_weight_unit), + style = MaterialTheme.typography.labelLarge, + ) + ChoiceChips( + options = listOf( + WeightUnit.KG to stringResource(Res.string.label_kg), + WeightUnit.LB to stringResource(Res.string.label_lbs), + ), + selected = core.weightUnit, + enabled = enabled, + onSelected = { onCoreChange(coreAfterWeightUnitSelection(core, it)) }, + ) + + HorizontalDivider() + Text( + text = stringResource(Res.string.profile_weight_increment), + style = MaterialTheme.typography.labelLarge, + ) + val unitLabel = if (core.weightUnit == WeightUnit.KG) { + stringResource(Res.string.label_kg) + } else { + stringResource(Res.string.label_lbs) + } + val incrementOptions = weightIncrementOptionsFor(core.weightUnit) + val selectedIncrement = displayedWeightIncrement(core) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + incrementOptions.forEach { increment -> + FilterChip( + selected = abs(selectedIncrement - increment) < 0.001f, + onClick = { onCoreChange(core.copy(weightIncrement = increment)) }, + enabled = enabled, + label = { + Text("${UnitConverter.formatDecimal(increment)} $unitLabel") + }, + ) + } + } + + HorizontalDivider() + Text( + text = stringResource(Res.string.profile_body_weight), + style = MaterialTheme.typography.labelLarge, + ) + OutlinedTextField( + bodyWeightDraft, + { candidate -> + if (candidate.matches(Regex("^\\d{0,3}(\\.\\d{0,2})?$"))) { + bodyWeightDraft = candidate + bodyWeightDraftEdited = true + } + }, + modifier = Modifier.fillMaxWidth(), + enabled = enabled, + singleLine = true, + label = { + Text( + if (authoritativeBodyWeight > 0f) { + unitLabel + } else { + stringResource(Res.string.profile_body_weight_unset) + }, + ) + }, + isError = bodyWeightDraft.isNotBlank() && bodyWeightInvalid, + supportingText = { + when { + bodyWeightDraft.isNotBlank() && bodyWeightInvalid -> { + Text(stringResource(Res.string.profile_body_weight_invalid)) + } + importedBodyWeightMeasuredAt != null -> { + Text(stringResource(Res.string.profile_body_weight_imported)) + } + } + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Decimal, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { + if (bodyWeightKgToSave != null) { + onCoreChange(core.copy(bodyWeightKg = bodyWeightKgToSave)) + } + }, + ), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton( + onClick = { + bodyWeightDraft = "" + bodyWeightDraftEdited = true + onCoreChange(core.copy(bodyWeightKg = 0f)) + }, + enabled = enabled && authoritativeBodyWeight > 0f, + ) { + Text(stringResource(Res.string.action_clear)) + } + Button( + onClick = { + if (bodyWeightKgToSave != null) { + onCoreChange(core.copy(bodyWeightKg = bodyWeightKgToSave)) + } + }, + enabled = enabled && !bodyWeightInvalid, + ) { + Text(stringResource(Res.string.action_save)) + } + } + } +} + +@Composable +private fun WorkoutPreferenceCard( + workout: WorkoutPreferences, + busySections: Set, + onWorkoutChange: (WorkoutPreferences) -> Long?, +) { + val enabled = ProfilePreferenceSection.WORKOUT !in busySections + + PreferenceCard(title = stringResource(Res.string.profile_workout_behavior)) { + IntegerChoiceRow( + label = stringResource(Res.string.profile_set_summary), + value = workout.summaryCountdownSeconds, + options = listOf(-1, 0, 5, 10, 15, 20, 25, 30), + enabled = enabled, + valueLabel = { seconds -> + if (seconds == -1) stringResource(Res.string.profile_automatic) else "${seconds}s" + }, + onSelected = { onWorkoutChange(workout.copy(summaryCountdownSeconds = it)) }, + ) + IntegerChoiceRow( + label = stringResource(Res.string.profile_autostart_countdown), + value = workout.autoStartCountdownSeconds, + options = (2..10).toList(), + enabled = enabled, + valueLabel = { "${it}s" }, + onSelected = { onWorkoutChange(workout.copy(autoStartCountdownSeconds = it)) }, + ) + PreferenceSwitchRow( + label = stringResource(Res.string.profile_auto_start_routine), + checked = workout.autoStartRoutine, + enabled = enabled, + onCheckedChange = { onWorkoutChange(workout.copy(autoStartRoutine = it)) }, + ) + PreferenceSwitchRow( + label = stringResource(Res.string.profile_motion_start), + checked = workout.motionStartEnabled, + enabled = enabled, + onCheckedChange = { onWorkoutChange(workout.copy(motionStartEnabled = it)) }, + ) + PreferenceSwitchRow( + label = stringResource(Res.string.profile_master_beeps), + checked = workout.beepsEnabled, + enabled = enabled, + onCheckedChange = { onWorkoutChange(workout.copy(beepsEnabled = it)) }, + ) + PreferenceSwitchRow( + label = stringResource(Res.string.profile_audio_rep_counter), + checked = workout.audioRepCountEnabled, + enabled = enabled, + onCheckedChange = { onWorkoutChange(workout.copy(audioRepCountEnabled = it)) }, + ) + PreferenceSwitchRow( + label = stringResource(Res.string.profile_countdown_beeps), + checked = workout.countdownBeepsEnabled, + enabled = enabled && workout.beepsEnabled, + onCheckedChange = { onWorkoutChange(workout.copy(countdownBeepsEnabled = it)) }, + ) + PreferenceSwitchRow( + label = stringResource(Res.string.profile_rep_completion_sound), + checked = workout.repSoundEnabled, + enabled = enabled && workout.beepsEnabled, + onCheckedChange = { onWorkoutChange(workout.copy(repSoundEnabled = it)) }, + ) + PreferenceSwitchRow( + label = stringResource(Res.string.profile_gamification), + checked = workout.gamificationEnabled, + enabled = enabled, + onCheckedChange = { onWorkoutChange(workout.copy(gamificationEnabled = it)) }, + ) + PreferenceSwitchRow( + label = stringResource(Res.string.settings_weight_suggestions_title), + checked = workout.weightSuggestionsEnabled, + enabled = enabled, + onCheckedChange = { onWorkoutChange(workout.copy(weightSuggestionsEnabled = it)) }, + ) + PreferenceSwitchRow( + label = stringResource(Res.string.settings_voice_stop_enable), + supporting = if (workout.voiceStopEnabled) { + stringResource(Res.string.settings_voice_stop_description) + } else { + null + }, + checked = workout.voiceStopEnabled, + enabled = enabled, + onCheckedChange = { onWorkoutChange(workout.copy(voiceStopEnabled = it)) }, + ) + } +} + +@Composable +private fun LedPreferenceCard( + profileId: String, + led: LedPreferences, + busySections: Set, + isConnected: Boolean, + discoModeActive: Boolean, + onLedChange: (LedPreferences) -> Long?, + onUnlockDiscoMode: () -> Long?, + onDiscoModeToggle: (Boolean) -> Unit, +) { + val enabled = ProfilePreferenceSection.LED !in busySections + var tapCount by rememberSaveable(profileId, led.discoModeUnlocked, enabled) { + mutableIntStateOf(0) + } + var firstTapAt by rememberSaveable(profileId, led.discoModeUnlocked, enabled) { + mutableLongStateOf(0L) + } + val schemes = listOf( + 0 to Res.string.profile_led_scheme_blue, + 1 to Res.string.profile_led_scheme_green, + 2 to Res.string.profile_led_scheme_teal, + 3 to Res.string.profile_led_scheme_yellow, + 4 to Res.string.profile_led_scheme_pink, + 5 to Res.string.profile_led_scheme_red, + 6 to Res.string.profile_led_scheme_purple, + 7 to Res.string.profile_led_scheme_none, + ) + val selectedScheme = normalizedLedSchemeIndex(led.colorScheme, schemes.size) + val selectedSchemeName = stringResource(schemes[selectedScheme].second) + + PreferenceCard( + title = stringResource(Res.string.profile_led), + titleModifier = Modifier.clickable(enabled = enabled && !led.discoModeUnlocked) { + val now = KmpUtils.currentTimeMillis() + if (firstTapAt == 0L || now - firstTapAt > 2000L) { + firstTapAt = now + tapCount = 1 + } else { + tapCount += 1 + } + if (tapCount >= 7) { + onUnlockDiscoMode() + tapCount = 0 + firstTapAt = 0L + } + }, + ) { + Text( + text = stringResource(Res.string.profile_led_selected_scheme, selectedSchemeName), + style = MaterialTheme.typography.labelLarge, + ) + LazyRow( + modifier = Modifier.fillMaxWidth().selectableGroup(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(schemes, key = { it.first }) { (index, labelResource) -> + val label = stringResource(labelResource) + val selected = selectedScheme == index + val schemeDescription = stringResource(Res.string.cd_select_led_scheme, label) + val scheme = ColorSchemes.ALL[index] + val gradient = Brush.linearGradient( + colors = scheme.colors.map { Color(it.r, it.g, it.b) }, + ) + val offCrossColor = MaterialTheme.colorScheme.onSurface + Box( + modifier = Modifier.size(48.dp) + .semantics { contentDescription = schemeDescription } + .selectable( + selected = selected, + enabled = enabled, + role = Role.RadioButton, + onClick = { onLedChange(led.copy(colorScheme = index)) }, + ), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier.size(40.dp) + .clip(CircleShape) + .background(gradient) + .border( + width = if (selected) 3.dp else 1.dp, + color = if (selected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outline + }, + shape = CircleShape, + ), + ) + if (index == ColorSchemes.ALL.lastIndex) { + Canvas(Modifier.size(28.dp)) { + drawLine( + color = offCrossColor, + start = androidx.compose.ui.geometry.Offset(2f, 2f), + end = androidx.compose.ui.geometry.Offset(size.width - 2f, size.height - 2f), + strokeWidth = 3.dp.toPx(), + ) + } + } + if (selected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = Color.White, + ) + } + } + } + } + if (led.discoModeUnlocked) { + PreferenceSwitchRow( + label = stringResource(Res.string.profile_disco_mode), + supporting = if (isConnected) { + null + } else { + stringResource(Res.string.profile_disco_requires_connection) + }, + checked = discoModeActive, + enabled = isConnected, + onCheckedChange = onDiscoModeToggle, + ) + } + } +} + +@Composable +private fun VbtPreferenceCard( + profileId: String, + vbt: VbtPreferences, + localSafety: ProfileLocalSafetyPreferences, + busySections: Set, + onVbtChange: (VbtPreferences) -> Long?, + onRequestAdultsOnlyConfirmation: () -> Unit, + onUnlockDominatrixMode: () -> Long?, +) { + val sectionEnabled = ProfilePreferenceSection.VBT !in busySections + val authoritativeVelocityThreshold = vbt.velocityLossThresholdPercent.toFloat() + var velocityThresholdDraft by rememberSaveable(profileId, authoritativeVelocityThreshold) { + mutableFloatStateOf(authoritativeVelocityThreshold) + } + LaunchedEffect(profileId, authoritativeVelocityThreshold) { + velocityThresholdDraft = authoritativeVelocityThreshold + } + val dominatrixUnlockEligible = localSafety.adultsOnlyConfirmed && vbt.enabled && + vbt.verbalEncouragementEnabled && vbt.vulgarModeEnabled && !vbt.dominatrixModeUnlocked + var dominatrixTapCount by rememberSaveable( + profileId, + dominatrixUnlockEligible, + vbt.dominatrixModeUnlocked, + ) { mutableIntStateOf(0) } + var dominatrixFirstTapAt by rememberSaveable( + profileId, + dominatrixUnlockEligible, + vbt.dominatrixModeUnlocked, + ) { mutableLongStateOf(0L) } + + PreferenceCard( + title = stringResource(Res.string.profile_vbt), + titleModifier = Modifier.clickable( + enabled = sectionEnabled && dominatrixUnlockEligible, + ) { + val now = KmpUtils.currentTimeMillis() + if (dominatrixFirstTapAt == 0L || now - dominatrixFirstTapAt > 2000L) { + dominatrixFirstTapAt = now + dominatrixTapCount = 1 + } else { + dominatrixTapCount += 1 + } + if (dominatrixTapCount >= 7) { + onUnlockDominatrixMode() + dominatrixTapCount = 0 + dominatrixFirstTapAt = 0L + } + }, + ) { + PreferenceSwitchRow( + label = stringResource(Res.string.profile_vbt_enabled), + checked = vbt.enabled, + enabled = sectionEnabled, + onCheckedChange = { onVbtChange(vbtAfterEnabledSelection(vbt, it)) }, + ) + Text( + stringResource(Res.string.profile_vbt_history_note), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = stringResource(Res.string.profile_velocity_loss_threshold), + style = MaterialTheme.typography.labelLarge, + ) + Text( + text = "${velocityThresholdDraft.roundToInt()}%", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + ) + Slider( + value = velocityThresholdDraft, + onValueChange = { velocityThresholdDraft = it.roundToInt().toFloat() }, + onValueChangeFinished = { + onVbtChange( + vbt.copy( + velocityLossThresholdPercent = velocityThresholdDraft.roundToInt(), + ), + ) + }, + valueRange = 10f..50f, + steps = 39, + enabled = ProfilePreferenceSection.VBT !in busySections && vbt.enabled, + ) + PreferenceSwitchRow( + label = stringResource(Res.string.profile_auto_end_velocity_loss), + checked = vbt.autoEndOnVelocityLoss, + enabled = sectionEnabled && vbt.enabled, + onCheckedChange = { onVbtChange(vbt.copy(autoEndOnVelocityLoss = it)) }, + ) + ChoiceChips( + label = stringResource(Res.string.profile_default_scaling_basis), + options = listOf( + ScalingBasis.MAX_WEIGHT_PR to stringResource(Res.string.profile_pr_max_weight), + ScalingBasis.MAX_VOLUME_PR to stringResource(Res.string.profile_pr_max_volume), + ScalingBasis.ESTIMATED_1RM to stringResource(Res.string.profile_pr_estimated_one_rep_max), + ), + selected = vbt.defaultScalingBasis, + enabled = sectionEnabled && vbt.enabled, + onSelected = { onVbtChange(vbt.copy(defaultScalingBasis = it)) }, + ) + PreferenceSwitchRow( + label = stringResource(Res.string.settings_verbal_encouragement_title), + checked = vbt.verbalEncouragementEnabled, + enabled = sectionEnabled && vbt.enabled, + onCheckedChange = { requested -> + onVbtChange(vbtAfterVerbalEncouragementSelection(vbt, requested)) + }, + ) + PreferenceSwitchRow( + label = stringResource(Res.string.settings_vulgar_mode_title), + supporting = stringResource(Res.string.settings_vulgar_mode_headphone_warning), + checked = vbt.vulgarModeEnabled, + enabled = sectionEnabled && vbt.enabled && vbt.verbalEncouragementEnabled, + onCheckedChange = { requested -> + if (requested) { + if (localSafety.adultsOnlyConfirmed) { + onVbtChange(vbtAfterVulgarModeSelection(vbt, true)) + } else { + onRequestAdultsOnlyConfirmation() + } + } else { + onVbtChange(vbtAfterVulgarModeSelection(vbt, false)) + } + }, + ) + ChoiceChips( + label = stringResource(Res.string.settings_vulgar_mode_tier_label), + options = listOf( + VulgarTier.MILD to stringResource(Res.string.settings_vulgar_mode_tier_mild), + VulgarTier.STRONG to stringResource(Res.string.settings_vulgar_mode_tier_strong), + VulgarTier.MIX to stringResource(Res.string.settings_vulgar_mode_tier_mix), + ), + selected = vbt.vulgarTier, + enabled = sectionEnabled && vbt.enabled && vbt.verbalEncouragementEnabled && localSafety.adultsOnlyConfirmed && vbt.vulgarModeEnabled, + onSelected = { onVbtChange(vbt.copy(vulgarTier = it)) }, + ) + val dominatrixEnabled = localSafety.adultsOnlyConfirmed && vbt.enabled && + vbt.verbalEncouragementEnabled && vbt.vulgarModeEnabled && + vbt.dominatrixModeUnlocked + if (vbt.dominatrixModeUnlocked) { + PreferenceSwitchRow( + label = stringResource(Res.string.settings_dominatrix_mode_title), + supporting = stringResource(Res.string.settings_dominatrix_mode_description), + checked = vbt.dominatrixModeActive, + enabled = sectionEnabled && dominatrixEnabled, + onCheckedChange = { onVbtChange(vbt.copy(dominatrixModeActive = it)) }, + ) + } + } +} + +@Composable +private fun SafetyPreferenceCard( + profileId: String, + localSafety: ProfileLocalSafetyPreferences, + voiceStopEnabled: Boolean, + busySections: Set, + onLocalSafetyChange: (ProfileLocalSafetyPreferences) -> Long?, +) { + val enabled = ProfilePreferenceSection.LOCAL_SAFETY !in busySections + val authoritativeSafeWord = localSafety.safeWord.orEmpty() + var safeWordDraft by rememberSaveable(profileId, authoritativeSafeWord) { + mutableStateOf(authoritativeSafeWord) + } + var showCalibrationDialog by rememberSaveable(profileId) { mutableStateOf(false) } + LaunchedEffect(profileId, authoritativeSafeWord) { safeWordDraft = authoritativeSafeWord } + + PreferenceCard(title = stringResource(Res.string.profile_safety)) { + if (voiceStopEnabled) { + Text( + stringResource(Res.string.settings_voice_stop_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + OutlinedTextField( + safeWordDraft, + { safeWordDraft = it.take(40) }, + modifier = Modifier.fillMaxWidth(), + enabled = enabled, + singleLine = true, + label = { Text(stringResource(Res.string.settings_safe_word_label)) }, + placeholder = { Text(stringResource(Res.string.settings_safe_word_hint)) }, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + ) { + OutlinedButton( + onClick = { showCalibrationDialog = true }, + enabled = enabled && safeWordDraft.isNotBlank(), + ) { + Text(stringResource(Res.string.settings_calibrate_button)) + } + Button( + onClick = { + val normalized = safeWordDraft.trim().takeIf { it.isNotEmpty() } + onLocalSafetyChange( + localSafety.copy( + safeWord = normalized, + safeWordCalibrated = localSafety.safeWordCalibrated && + normalized == localSafety.safeWord, + ), + ) + }, + enabled = enabled, + ) { + Text(stringResource(Res.string.action_save)) + } + } + if (localSafety.safeWordCalibrated) { + Text( + stringResource(Res.string.settings_calibrated_badge), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + } else { + Text( + stringResource(Res.string.settings_calibrate_first), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + if (showCalibrationDialog && safeWordDraft.isNotBlank()) { + SafeWordCalibrationDialog( + safeWord = safeWordDraft.trim(), + onCalibrated = { + onLocalSafetyChange( + localSafety.copy( + safeWord = safeWordDraft.trim(), + safeWordCalibrated = true, + ), + ) + showCalibrationDialog = false + }, + onDismiss = { showCalibrationDialog = false }, + ) + } +} + +@Composable +private fun PreferenceCard( + title: String, + titleModifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = titleModifier.fillMaxWidth().heightIn(min = 48.dp) + .padding(vertical = 12.dp), + ) + content() + } + } +} + +@Composable +private fun PreferenceSwitchRow( + label: String, + checked: Boolean, + enabled: Boolean, + onCheckedChange: (Boolean) -> Unit, + supporting: String? = null, +) { + Row( + modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text(label, style = MaterialTheme.typography.bodyLarge) + supporting?.let { + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Switch(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled) + } +} + +@Composable +@OptIn(ExperimentalLayoutApi::class) +private fun ChoiceChips( + options: List>, + selected: T, + enabled: Boolean, + onSelected: (T) -> Unit, + label: String? = null, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + label?.let { Text(it, style = MaterialTheme.typography.labelLarge) } + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + options.forEach { (value, text) -> + FilterChip( + selected = value == selected, + onClick = { onSelected(value) }, + enabled = enabled, + label = { Text(text) }, + ) + } + } + } +} + +@Composable +private fun IntegerChoiceRow( + label: String, + value: Int, + options: List, + enabled: Boolean, + valueLabel: @Composable (Int) -> String, + onSelected: (Int) -> Unit, +) { + var expanded by rememberSaveable { mutableStateOf(false) } + Row( + modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(label, modifier = Modifier.weight(1f)) + Box { + TextButton(onClick = { expanded = true }, enabled = enabled) { + Text(valueLabel(value)) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + options.forEach { option -> + DropdownMenuItem( + text = { Text(valueLabel(option)) }, + onClick = { + expanded = false + onSelected(option) + }, + ) + } + } + } + } +} + +private fun displayBodyWeight(bodyWeightKg: Float, unit: WeightUnit): String { + if (!bodyWeightKg.isFinite() || bodyWeightKg <= 0f) return "" + val display = if (unit == WeightUnit.LB) UnitConverter.kgToLb(bodyWeightKg) else bodyWeightKg + return UnitConverter.formatDecimal(display) +} + +internal fun coreAfterWeightUnitSelection( + core: CoreProfilePreferences, + selectedUnit: WeightUnit, +): CoreProfilePreferences { + if (selectedUnit == core.weightUnit) return core + val defaultIncrement = when (selectedUnit) { + WeightUnit.KG -> Constants.DEFAULT_WEIGHT_INCREMENT_KG + WeightUnit.LB -> Constants.DEFAULT_WEIGHT_INCREMENT_LB + } + return core.copy(weightUnit = selectedUnit, weightIncrement = defaultIncrement) +} + +internal fun weightIncrementOptionsFor(weightUnit: WeightUnit): List = when (weightUnit) { + WeightUnit.KG -> Constants.WEIGHT_INCREMENT_OPTIONS_KG + WeightUnit.LB -> Constants.WEIGHT_INCREMENT_OPTIONS_LB +} + +internal fun displayedWeightIncrement(core: CoreProfilePreferences): Float = + core.weightIncrement.takeIf { it >= 0f } ?: when (core.weightUnit) { + WeightUnit.KG -> Constants.DEFAULT_WEIGHT_INCREMENT_KG + WeightUnit.LB -> Constants.DEFAULT_WEIGHT_INCREMENT_LB + } + +internal fun vbtAfterEnabledSelection(vbt: VbtPreferences, enabled: Boolean): VbtPreferences = + if (enabled) { + vbt.copy(enabled = true) + } else { + vbt.copy(enabled = false, dominatrixModeActive = false) + } + +internal fun vbtAfterVerbalEncouragementSelection( + vbt: VbtPreferences, + enabled: Boolean, +): VbtPreferences = if (enabled) { + vbt.copy(verbalEncouragementEnabled = true) +} else { + vbt.copy( + verbalEncouragementEnabled = false, + vulgarModeEnabled = false, + dominatrixModeActive = false, + ) +} + +internal fun vbtAfterVulgarModeSelection( + vbt: VbtPreferences, + enabled: Boolean, +): VbtPreferences = if (enabled) { + vbt.copy(vulgarModeEnabled = true) +} else { + vbt.copy(vulgarModeEnabled = false, dominatrixModeActive = false) +} + +internal fun bodyWeightKgForSave( + authoritativeBodyWeightKg: Float, + draft: String, + weightUnit: WeightUnit, + draftEdited: Boolean, +): Float? { + if (draft.isBlank()) return null + val bodyWeightKg = if (draftEdited) { + val displayWeight = draft.toFloatOrNull() ?: return null + if (weightUnit == WeightUnit.LB) UnitConverter.lbToKg(displayWeight) else displayWeight + } else { + authoritativeBodyWeightKg + } + return bodyWeightKg.takeIf { it.isFinite() && it in 20f..300f } +} diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfilePreferencePolicy.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfilePreferencePolicy.kt new file mode 100644 index 000000000..bcd27655b --- /dev/null +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfilePreferencePolicy.kt @@ -0,0 +1,35 @@ +package com.devil.phoenixproject.presentation.components + +import com.devil.phoenixproject.data.integration.HealthBodyWeightSyncManager +import com.devil.phoenixproject.domain.model.ExternalBodyMeasurement +import com.devil.phoenixproject.domain.model.IntegrationProvider +import kotlin.math.abs + +internal data class ProfileMeasurementKey( + val profileId: String, + val coreLocalGeneration: Long, + val bodyWeightKg: Float, +) + +internal fun normalizedLedSchemeIndex( + storedIndex: Int, + schemeCount: Int, +): Int = storedIndex.takeIf { it in 0 until schemeCount } ?: 0 + +internal fun latestImportedBodyWeightMeasuredAt( + profileId: String, + bodyWeightKg: Float, + measurements: List, +): Long? { + if (!bodyWeightKg.isFinite() || bodyWeightKg <= 0f) return null + return measurements.asSequence() + .filter { it.profileId == profileId } + .filter { it.measurementType == HealthBodyWeightSyncManager.MEASUREMENT_TYPE_WEIGHT } + .filter { it.unit == HealthBodyWeightSyncManager.UNIT_KG } + .filter { + it.provider == IntegrationProvider.APPLE_HEALTH || + it.provider == IntegrationProvider.GOOGLE_HEALTH + } + .filter { abs(it.value - bodyWeightKg.toDouble()) < 0.05 } + .maxOfOrNull(ExternalBodyMeasurement::measuredAt) +} diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileSafetyDialogs.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileSafetyDialogs.kt new file mode 100644 index 000000000..ac1368775 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileSafetyDialogs.kt @@ -0,0 +1,505 @@ +package com.devil.phoenixproject.presentation.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.scale +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import com.devil.phoenixproject.domain.voice.SafeWordListener +import com.devil.phoenixproject.domain.voice.SafeWordListenerFactory +import com.devil.phoenixproject.presentation.util.LocalPlatformAccessibilitySettings +import com.devil.phoenixproject.ui.theme.ExpressiveMotion +import com.devil.phoenixproject.ui.theme.ForgeGreen +import com.devil.phoenixproject.ui.theme.Spacing +import com.devil.phoenixproject.util.openAppSettings +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject +import vitruvianprojectphoenix.shared.generated.resources.* + +/** Calibrates a profile's safe word with three successful local detections. */ +@Composable +fun SafeWordCalibrationDialog( + safeWord: String, + onCalibrated: () -> Unit, + onDismiss: () -> Unit, +) { + val listenerFactory: SafeWordListenerFactory = koinInject() + var detectionCount by remember { mutableStateOf(0) } + var micError by remember { mutableStateOf(false) } + var listener by remember { mutableStateOf(null) } + + DisposableEffect(safeWord) { + val newListener = try { + listenerFactory.create(safeWord) + } catch (_: Exception) { + micError = true + null + } + listener = newListener + newListener?.startListening() + + onDispose { + newListener?.stopListening() + listener = null + } + } + + val currentListener = listener + if (currentListener != null) { + val isListening by currentListener.isListening.collectAsState() + + LaunchedEffect(currentListener) { + currentListener.detectedWord.collect { + detectionCount += 1 + if (detectionCount >= 3) { + currentListener.stopListening() + onCalibrated() + } + } + } + + LaunchedEffect(isListening) { + if (!isListening && detectionCount == 0) { + kotlinx.coroutines.delay(3000) + if (!currentListener.isListening.value && detectionCount == 0) micError = true + } + } + } + + AlertDialog( + onDismissRequest = onDismiss, + shape = MaterialTheme.shapes.medium, + title = { + Text( + stringResource(Res.string.settings_calibration_title), + fontWeight = FontWeight.Bold, + ) + }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(Spacing.medium), + ) { + if (micError) { + Text( + stringResource(Res.string.settings_calibration_mic_error), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + OutlinedButton( + onClick = { openAppSettings() }, + shape = MaterialTheme.shapes.small, + ) { + Text(stringResource(Res.string.settings_calibration_open_settings)) + } + } else { + Text( + stringResource(Res.string.settings_calibration_prompt), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + safeWord, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + CalibrationProgress(detectionCount) + Text( + stringResource(Res.string.settings_calibration_listening), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(Res.string.action_cancel)) + } + }, + ) +} + +@Composable +private fun CalibrationProgress(detectionCount: Int) { + val reduceMotion = LocalPlatformAccessibilitySettings.current.reduceMotion + Row( + horizontalArrangement = Arrangement.spacedBy(Spacing.medium), + modifier = Modifier.padding(vertical = Spacing.small), + ) { + repeat(3) { index -> + val filled = index < detectionCount + val circleColor by animateColorAsState( + targetValue = if (filled) ForgeGreen else MaterialTheme.colorScheme.surfaceVariant, + animationSpec = if (reduceMotion) snap() else ExpressiveMotion.SpringBouncyColor, + label = "circleColor$index", + ) + val circleScale by animateFloatAsState( + targetValue = if (filled || reduceMotion) 1f else 0.8f, + animationSpec = if (reduceMotion) snap() else ExpressiveMotion.SpringBouncy, + label = "circleScale$index", + ) + val checkScale by animateFloatAsState( + targetValue = if (filled) 1f else 0f, + animationSpec = if (reduceMotion) snap() else ExpressiveMotion.SpringBouncy, + label = "checkScale$index", + ) + Box( + modifier = Modifier + .size(40.dp) + .scale(circleScale) + .background(circleColor, RoundedCornerShape(50)), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Default.Check, + contentDescription = if (filled) { + stringResource(Res.string.cd_calibration_check) + } else { + null + }, + tint = Color.White, + modifier = Modifier.size(24.dp).scale(checkScale).alpha(checkScale), + ) + } + } + } + Text( + stringResource(Res.string.settings_calibration_progress, detectionCount), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +@Composable +private fun AdultModeDialogCard( + presentation: AdultModePresentation, + content: @Composable () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth().shadow(8.dp, MaterialTheme.shapes.medium), + colors = CardDefaults.cardColors( + containerColor = adultModeContainerColor(presentation.containerTone), + ), + shape = MaterialTheme.shapes.medium, + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), + border = BorderStroke(2.dp, adultModeBorderColor(presentation)), + ) { + content() + } +} + +@Composable +private fun adultModeContainerColor(tone: AdultModeDialogTone): Color = when (tone) { + AdultModeDialogTone.ThemeSurface -> MaterialTheme.colorScheme.surfaceContainerHighest +} + +@Composable +private fun adultModeBorderColor(presentation: AdultModePresentation): Color = when { + presentation.usesBespokePinkAccent -> MaterialTheme.colorScheme.error.copy(alpha = 0.24f) + presentation.usesBrandAccent -> MaterialTheme.colorScheme.primary.copy(alpha = 0.2f) + else -> MaterialTheme.colorScheme.outlineVariant +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AdultsOnlyConfirmDialog( + isSubmitting: Boolean, + errorMessage: String?, + onConfirm: () -> Unit, + onDecline: () -> Unit, + onDismiss: () -> Unit, +) { + val presentation = AdultModePresentation.adultsOnlyConfirmation() + + BasicAlertDialog( + onDismissRequest = { if (!isSubmitting) onDismiss() }, + properties = DialogProperties( + dismissOnBackPress = !isSubmitting, + dismissOnClickOutside = !isSubmitting, + ), + ) { + AdultModeDialogCard(presentation = presentation) { + Column( + modifier = Modifier.fillMaxWidth().padding(Spacing.large), + verticalArrangement = Arrangement.spacedBy(Spacing.medium), + ) { + Surface( + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.errorContainer, + ) { + Row( + modifier = Modifier.fillMaxWidth() + .padding(horizontal = Spacing.medium, vertical = Spacing.small), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Default.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(Spacing.small)) + Text( + stringResource(Res.string.settings_adults_only_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + } + } + Text( + stringResource(Res.string.settings_adults_only_body), + style = MaterialTheme.typography.bodyMedium, + ) + Surface( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Text( + stringResource(Res.string.settings_adults_only_compliance_footer), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(Spacing.medium), + ) + } + errorMessage?.let { message -> + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, + ) + } + Button( + onClick = onConfirm, + enabled = !isSubmitting, + modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp), + shape = MaterialTheme.shapes.extraLarge, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + ) { + Text( + stringResource(Res.string.settings_adults_only_confirm), + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ) + } + OutlinedButton( + onClick = onDecline, + enabled = !isSubmitting, + modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp), + shape = MaterialTheme.shapes.extraLarge, + ) { + Text( + stringResource(Res.string.settings_adults_only_decline), + textAlign = TextAlign.Center, + ) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DominatrixUnlockDialog(onDismiss: () -> Unit) { + LaunchedEffect(Unit) { + kotlinx.coroutines.delay(4000) + onDismiss() + } + var isVisible by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { isVisible = true } + val scale by animateFloatAsState( + targetValue = if (isVisible) 1f else 0f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium, + ), + label = "dominatrix_dialog_scale", + ) + val presentation = AdultModePresentation.dominatrixUnlock() + + BasicAlertDialog(onDismissRequest = onDismiss, modifier = Modifier.scale(scale)) { + AdultModeDialogCard(presentation) { + Column( + modifier = Modifier.fillMaxWidth().padding(Spacing.large), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Surface( + modifier = Modifier.size(72.dp), + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + ) { + Box(contentAlignment = Alignment.Center) { + Text("👑", style = MaterialTheme.typography.displayMedium) + } + } + Spacer(modifier = Modifier.height(Spacing.medium)) + Text( + stringResource(Res.string.settings_dominatrix_unlock_toast), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(Spacing.small)) + Text( + stringResource(Res.string.settings_dominatrix_mode_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(Spacing.large)) + Button( + onClick = onDismiss, + modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp), + ) { + Text(stringResource(Res.string.action_ok)) + } + } + } + } +} + +@Composable +fun DiscoModeUnlockDialog(onDismiss: () -> Unit) { + LaunchedEffect(Unit) { + kotlinx.coroutines.delay(4000) + onDismiss() + } + var isVisible by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { isVisible = true } + val scale by animateFloatAsState( + targetValue = if (isVisible) 1f else 0f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium, + ), + label = "disco_dialog_scale", + ) + val reduceMotion = LocalPlatformAccessibilitySettings.current.reduceMotion + val transition = rememberInfiniteTransition(label = "disco") + val rotation by transition.animateFloat( + initialValue = 0f, + targetValue = if (reduceMotion) 0f else 360f, + animationSpec = infiniteRepeatable( + animation = tween(1920, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "disco_rotation", + ) + val glowAlpha by transition.animateFloat( + initialValue = 0.3f, + targetValue = if (reduceMotion) 0.3f else 0.8f, + animationSpec = infiniteRepeatable( + animation = tween(400, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "disco_glow", + ) + + AlertDialog( + onDismissRequest = onDismiss, + modifier = Modifier.scale(scale), + title = { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box( + modifier = Modifier.size(80.dp).rotate(rotation).background( + Brush.radialGradient( + listOf(Color.White.copy(alpha = glowAlpha), Color.Transparent), + ), + RoundedCornerShape(40.dp), + ), + contentAlignment = Alignment.Center, + ) { + Text("🪩", style = MaterialTheme.typography.displayLarge) + } + Text( + stringResource(Res.string.profile_disco_unlocked_title), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.ExtraBold, + textAlign = TextAlign.Center, + ) + } + }, + text = { + Text( + stringResource(Res.string.profile_disco_unlocked_body), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) + }, + confirmButton = { + TextButton(onClick = onDismiss, modifier = Modifier.heightIn(min = 48.dp)) { + Text( + stringResource(Res.string.profile_disco_unlocked_action), + fontWeight = FontWeight.Bold, + ) + } + }, + ) +} diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileSidePanel.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileSidePanel.kt deleted file mode 100644 index 96d0c0402..000000000 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileSidePanel.kt +++ /dev/null @@ -1,290 +0,0 @@ -package com.devil.phoenixproject.presentation.components - -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectHorizontalDragGestures -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.dp -import androidx.compose.ui.zIndex -import com.devil.phoenixproject.data.repository.UserProfile -import com.devil.phoenixproject.data.repository.UserProfileRepository -import com.devil.phoenixproject.presentation.util.LocalWindowSizeClass -import com.devil.phoenixproject.presentation.util.WindowWidthSizeClass -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import org.jetbrains.compose.resources.stringResource -import vitruvianprojectphoenix.shared.generated.resources.* -import vitruvianprojectphoenix.shared.generated.resources.Res -private val HANDLE_VISUAL_WIDTH = 24.dp // visual tab width (pre-Phase-1) -private val HANDLE_TOUCH_WIDTH = 48.dp // touch-target width (≥48dp requirement) -private val HANDLE_HEIGHT = 48.dp -private val EDGE_SWIPE_THRESHOLD = 20.dp - -/** - * Slide-in profile panel from right edge. - * Replaces ProfileSpeedDial FAB with a less intrusive side panel. - */ -@Composable -fun ProfileSidePanel( - profiles: List, - activeProfile: UserProfile?, - profileRepository: UserProfileRepository, - scope: CoroutineScope, - onAddProfile: () -> Unit, - modifier: Modifier = Modifier, -) { - var isOpen by remember { mutableStateOf(false) } - var showContextMenu by remember { mutableStateOf(null) } - var showEditDialog by remember { mutableStateOf(null) } - var showDeleteDialog by remember { mutableStateOf(null) } - - // Responsive panel width based on screen size - val windowSizeClass = LocalWindowSizeClass.current - val panelWidth = when (windowSizeClass.widthSizeClass) { - WindowWidthSizeClass.Expanded -> 280.dp - WindowWidthSizeClass.Medium -> 240.dp - WindowWidthSizeClass.Compact -> 200.dp - } - - val density = LocalDensity.current - val panelWidthPx = with(density) { panelWidth.toPx() } - - // Panel offset animation - val panelOffset by animateDpAsState( - targetValue = if (isOpen) 0.dp else panelWidth, - animationSpec = tween(durationMillis = 300), - label = "panelOffset", - ) - - Box(modifier = modifier.fillMaxSize()) { - // Scrim (when open) - if (isOpen) { - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.5f)) - .clickable { isOpen = false } - .zIndex(1f), - ) - } - - // Edge swipe detection zone - Box( - modifier = Modifier - .align(Alignment.CenterEnd) - .width(EDGE_SWIPE_THRESHOLD) - .fillMaxHeight() - .pointerInput(Unit) { - detectHorizontalDragGestures { _, dragAmount -> - if (dragAmount < -10) { - isOpen = true - } - } - }, - ) - - // Panel + Handle - Box( - modifier = Modifier - .align(Alignment.CenterEnd) - .offset { IntOffset(panelOffset.roundToPx(), 0) } - .zIndex(2f), - ) { - // Chevron handle: 48dp-wide transparent touch Box (hit area extends inward), - // containing the 24dp visual Surface aligned flush to the right/panel edge. - // Offset and panel geometry unchanged — offset uses HANDLE_TOUCH_WIDTH (= old HANDLE_WIDTH = 48dp). - Box( - modifier = Modifier - .offset(x = -HANDLE_TOUCH_WIDTH) - .align(Alignment.CenterStart) - .width(HANDLE_TOUCH_WIDTH) - .height(HANDLE_HEIGHT) - .clickable { isOpen = !isOpen }, - contentAlignment = Alignment.CenterEnd, - ) { - Surface( - modifier = Modifier - .width(HANDLE_VISUAL_WIDTH) - .fillMaxHeight(), - shape = RoundedCornerShape(topStart = 8.dp, bottomStart = 8.dp), - color = ProfileColors.getOrElse(activeProfile?.colorIndex ?: 0) { - ProfileColors[0] - }.copy(alpha = 0.9f), - shadowElevation = 4.dp, - ) { - Box(contentAlignment = Alignment.Center) { - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowLeft, - contentDescription = if (isOpen) "Close profiles" else "Open profiles", - tint = Color.White, - ) - } - } - } - - // Main panel - wraps content height - Surface( - modifier = Modifier - .width(panelWidth) - .pointerInput(Unit) { - detectHorizontalDragGestures { _, dragAmount -> - if (dragAmount > 20) { - isOpen = false - } - } - }, - shape = MaterialTheme.shapes.medium, - color = MaterialTheme.colorScheme.surfaceContainer, - shadowElevation = 8.dp, - ) { - Column { - // Header - Text( - text = "Profiles", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(16.dp), - ) - - HorizontalDivider() - - // Profile list - Column { - profiles.forEach { profile -> - val isActive = profile.id == activeProfile?.id - - Box { - ProfileListItem( - profile = profile, - isActive = isActive, - onClick = { - scope.launch { - profileRepository.setActiveProfile(profile.id) - } - isOpen = false - }, - onLongClick = { - showContextMenu = profile - }, - ) - - // Context menu dropdown - DropdownMenu( - expanded = showContextMenu?.id == profile.id, - onDismissRequest = { showContextMenu = null }, - ) { - DropdownMenuItem( - text = { Text(stringResource(Res.string.action_edit)) }, - onClick = { - showEditDialog = profile - showContextMenu = null - }, - leadingIcon = { - Icon(Icons.Default.Edit, contentDescription = null) - }, - ) - // Don't show delete for default profile - if (profile.id != "default") { - DropdownMenuItem( - text = { - Text(stringResource(Res.string.action_delete)) - }, - onClick = { - showDeleteDialog = profile - showContextMenu = null - }, - leadingIcon = { - Icon( - Icons.Default.Delete, - contentDescription = null, - tint = MaterialTheme.colorScheme.error, - ) - }, - ) - } - } - } - } - } - - HorizontalDivider() - - // Add profile button - Surface( - modifier = Modifier - .fillMaxWidth() - .clickable { - isOpen = false - onAddProfile() - }, - color = Color.Transparent, - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Surface( - modifier = Modifier.size(40.dp), - shape = androidx.compose.foundation.shape.CircleShape, - color = MaterialTheme.colorScheme.secondaryContainer, - ) { - Box(contentAlignment = Alignment.Center) { - Icon( - imageVector = Icons.Default.Add, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSecondaryContainer, - ) - } - } - Text( - text = "Add Profile", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - } - } - } - } - } - } - - // Edit dialog - showEditDialog?.let { profile -> - EditProfileDialog( - profile = profile, - profileRepository = profileRepository, - scope = scope, - onDismiss = { showEditDialog = null }, - ) - } - - // Delete dialog - showDeleteDialog?.let { profile -> - DeleteProfileDialog( - profile = profile, - profileRepository = profileRepository, - scope = scope, - onDismiss = { showDeleteDialog = null }, - ) - } -} diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileSpeedDial.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileSpeedDial.kt deleted file mode 100644 index 3d0f3979d..000000000 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileSpeedDial.kt +++ /dev/null @@ -1,230 +0,0 @@ -package com.devil.phoenixproject.presentation.components - -import androidx.compose.animation.* -import androidx.compose.animation.core.* -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Close -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.rotate -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import com.devil.phoenixproject.data.repository.UserProfile -import com.devil.phoenixproject.data.repository.UserProfileRepository -import com.devil.phoenixproject.ui.theme.Spacing -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import org.jetbrains.compose.resources.stringResource -import vitruvianprojectphoenix.shared.generated.resources.* -import vitruvianprojectphoenix.shared.generated.resources.Res - -// Profile color palette -val ProfileColors = listOf( - Color(0xFF3B82F6), // Blue - Color(0xFF10B981), // Green - Color(0xFFF59E0B), // Amber - Color(0xFFEF4444), // Red - Color(0xFF8B5CF6), // Purple - Color(0xFFEC4899), // Pink - Color(0xFF06B6D4), // Cyan - Color(0xFFF97316), // Orange -) - -// Constant for profile color count to avoid magic numbers -const val PROFILE_COLOR_COUNT = 8 - -@Composable -fun ProfileSpeedDial( - profiles: List, - activeProfile: UserProfile?, - onProfileSelected: (UserProfile) -> Unit, - onAddProfile: () -> Unit, - modifier: Modifier = Modifier, -) { - var expanded by remember { mutableStateOf(false) } - val rotation by animateFloatAsState( - targetValue = if (expanded) 45f else 0f, - animationSpec = spring(stiffness = Spring.StiffnessMedium), - label = "rotation", - ) - - Box( - modifier = modifier, - contentAlignment = Alignment.BottomEnd, - ) { - // Expanded menu items - AnimatedVisibility( - visible = expanded, - enter = fadeIn() + expandVertically(expandFrom = Alignment.Bottom), - exit = fadeOut() + shrinkVertically(shrinkTowards = Alignment.Bottom), - ) { - Column( - modifier = Modifier.padding(bottom = 72.dp, end = 4.dp), - horizontalAlignment = Alignment.End, - verticalArrangement = Arrangement.spacedBy(Spacing.small), - ) { - // Add profile button - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(Spacing.small), - ) { - Surface( - shape = MaterialTheme.shapes.extraSmall, - color = MaterialTheme.colorScheme.surfaceContainerHighest, - shadowElevation = 4.dp, - ) { - Text( - "Add Profile", - modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.Medium, - ) - } - SmallFloatingActionButton( - onClick = { - expanded = false - onAddProfile() - }, - containerColor = MaterialTheme.colorScheme.secondaryContainer, - ) { - Icon( - Icons.Default.Add, - contentDescription = stringResource(Res.string.cd_add_profile), - ) - } - } - - // Profile list - profiles.forEach { profile -> - val isActive = profile.id == activeProfile?.id - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(Spacing.small), - ) { - Surface( - shape = MaterialTheme.shapes.extraSmall, - color = MaterialTheme.colorScheme.surfaceContainerHighest, - shadowElevation = 4.dp, - ) { - Text( - profile.name, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), - style = MaterialTheme.typography.labelMedium, - fontWeight = if (isActive) FontWeight.Bold else FontWeight.Medium, - color = if (isActive) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurface - }, - ) - } - ProfileAvatar( - profile = profile, - isActive = isActive, - onClick = { - expanded = false - onProfileSelected(profile) - }, - ) - } - } - } - } - - // Main FAB showing active profile - FloatingActionButton( - onClick = { expanded = !expanded }, - modifier = Modifier.size(56.dp), - containerColor = if (expanded) { - MaterialTheme.colorScheme.surfaceContainerHighest - } else { - ProfileColors.getOrElse(activeProfile?.colorIndex ?: 0) { ProfileColors[0] } - }, - contentColor = Color.White, - ) { - if (expanded) { - Icon( - Icons.Default.Close, - contentDescription = stringResource(Res.string.cd_close_menu), - modifier = Modifier.rotate(rotation), - tint = MaterialTheme.colorScheme.onSurface, - ) - } else { - Text( - text = activeProfile?.name?.take(1)?.uppercase() ?: "?", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - ) - } - } - } -} - -@Composable -fun ProfileAvatar(profile: UserProfile, isActive: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { - val color = ProfileColors.getOrElse(profile.colorIndex) { ProfileColors[0] } - - SmallFloatingActionButton( - onClick = onClick, - modifier = modifier.shadow(if (isActive) 8.dp else 4.dp, CircleShape), - containerColor = color, - contentColor = Color.White, - ) { - Text( - text = profile.name.take(1).uppercase(), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - } -} - -/** - * Reusable dialog for adding a new user profile. - */ -@Composable -fun AddProfileDialog(profiles: List, profileRepository: UserProfileRepository, scope: CoroutineScope, onDismiss: () -> Unit) { - var newProfileName by remember { mutableStateOf("") } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(Res.string.add_profile)) }, - text = { - OutlinedTextField( - value = newProfileName, - onValueChange = { newProfileName = it }, - label = { Text(stringResource(Res.string.label_name)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - }, - confirmButton = { - TextButton( - onClick = { - if (newProfileName.isNotBlank()) { - scope.launch { - val colorIndex = profiles.size % PROFILE_COLOR_COUNT - profileRepository.createProfile(newProfileName.trim(), colorIndex) - } - onDismiss() - } - }, - enabled = newProfileName.isNotBlank(), - ) { - Text(stringResource(Res.string.action_add)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(Res.string.action_cancel)) - } - }, - ) -} diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileSwitcherSheet.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileSwitcherSheet.kt new file mode 100644 index 000000000..8d32cd7f0 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileSwitcherSheet.kt @@ -0,0 +1,122 @@ +package com.devil.phoenixproject.presentation.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetValue +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.devil.phoenixproject.data.repository.UserProfile +import com.devil.phoenixproject.presentation.util.TestTags +import org.jetbrains.compose.resources.stringResource +import vitruvianprojectphoenix.shared.generated.resources.Res +import vitruvianprojectphoenix.shared.generated.resources.add_profile +import vitruvianprojectphoenix.shared.generated.resources.profiles_title + +internal fun canDismissProfileSwitcher(switchingInFlight: Boolean): Boolean = + !switchingInFlight + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProfileSwitcherSheet( + profiles: List, + activeProfileId: String?, + switchingInFlight: Boolean, + switchingTargetProfileId: String?, + errorMessage: String?, + onSelectProfile: (UserProfile) -> Unit, + onAddProfile: () -> Unit, + onDismiss: () -> Unit, +) { + val currentSwitchingInFlight by rememberUpdatedState(switchingInFlight) + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true, + confirmValueChange = { targetValue -> + targetValue != SheetValue.Hidden || + canDismissProfileSwitcher(currentSwitchingInFlight) + }, + ) + + ModalBottomSheet( + onDismissRequest = { + if (canDismissProfileSwitcher(currentSwitchingInFlight)) onDismiss() + }, + sheetState = sheetState, + sheetGesturesEnabled = !switchingInFlight, + modifier = Modifier.testTag(TestTags.PROFILE_SWITCHER_SHEET), + ) { + Text( + text = stringResource(Res.string.profiles_title), + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), + ) + errorMessage?.let { message -> + Text( + text = message, + color = MaterialTheme.colorScheme.error, + modifier = Modifier + .padding(horizontal = 24.dp, vertical = 4.dp) + .semantics { liveRegion = LiveRegionMode.Polite }, + ) + } + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .selectableGroup(), + contentPadding = PaddingValues(bottom = 24.dp), + ) { + items( + items = profiles, + key = UserProfile::id, + ) { profile -> + ProfileListItem( + profile = profile, + isActive = profile.id == activeProfileId, + enabled = !switchingInFlight, + switching = profile.id == switchingTargetProfileId, + onClick = { onSelectProfile(profile) }, + ) + } + item { + ListItem( + headlineContent = { Text(stringResource(Res.string.add_profile)) }, + leadingContent = { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + ) + }, + modifier = Modifier + .fillMaxWidth() + .clickable( + enabled = !switchingInFlight, + role = Role.Button, + onClick = onAddProfile, + ) + .testTag(TestTags.ACTION_ADD_PROFILE), + ) + } + } + } +} diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt index 73c0c6f08..58e308830 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt @@ -7,6 +7,9 @@ import com.devil.phoenixproject.data.integration.HealthIntegration import com.devil.phoenixproject.data.integration.HealthWorkoutExportBuilder import com.devil.phoenixproject.data.integration.IntegrationSyncCursorRepository import com.devil.phoenixproject.data.preferences.PreferencesManager +import com.devil.phoenixproject.data.preferences.toDocument +import com.devil.phoenixproject.data.preferences.toLegacySingleExerciseDefaults +import com.devil.phoenixproject.data.repository.ActiveProfileContext import com.devil.phoenixproject.data.repository.AutoStopUiState import com.devil.phoenixproject.data.repository.BiomechanicsRepository import com.devil.phoenixproject.data.repository.BleRepository @@ -31,6 +34,7 @@ import com.devil.phoenixproject.domain.model.ConnectionStatus import com.devil.phoenixproject.domain.model.FiveThreeOneRoutineDetector import com.devil.phoenixproject.domain.model.HapticEvent import com.devil.phoenixproject.domain.model.IntegrationProvider +import com.devil.phoenixproject.domain.model.JustLiftDefaultsDocument import com.devil.phoenixproject.domain.model.ProgramMode import com.devil.phoenixproject.domain.model.RackItem import com.devil.phoenixproject.domain.model.RackItemBehavior @@ -45,6 +49,7 @@ import com.devil.phoenixproject.domain.model.RoutineLaunchOrigin import com.devil.phoenixproject.domain.model.SetQualitySummary import com.devil.phoenixproject.domain.model.SetType import com.devil.phoenixproject.domain.model.TrainingCycle +import com.devil.phoenixproject.domain.model.UserPreferences import com.devil.phoenixproject.domain.model.WeightAdjustmentInput import com.devil.phoenixproject.domain.model.WorkoutMetric import com.devil.phoenixproject.domain.model.WorkoutParameters @@ -80,6 +85,18 @@ import kotlinx.coroutines.launch import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.json.Json +internal fun UserPreferences.verbalEncouragementEventOrNull(): HapticEvent.VERBAL_ENCOURAGEMENT? { + if (!beepsEnabled || !verbalEncouragementEnabled) return null + val effectiveVulgar = vulgarModeEnabled && adultsOnlyConfirmed + val effectiveDominatrix = effectiveVulgar && + dominatrixModeUnlocked && dominatrixModeActive + return HapticEvent.VERBAL_ENCOURAGEMENT( + vulgarTier = vulgarTier, + dominatrixMode = effectiveDominatrix, + vulgarMode = effectiveVulgar, + ) +} + /** * Handles all workout lifecycle logic: start/stop, rep processing, auto-stop, * BLE commands, rest timer, session persistence, weight adjustment, Just Lift, @@ -1329,14 +1346,17 @@ class ActiveSessionEngine( Logger.e(e) { "Biomechanics processing failed for rep $repNumber" } } - // Issue #313: Check velocity threshold for alert and auto-end (working reps only) - if (coordinator._repCount.value.isWarmupComplete) { - checkVelocityThreshold() - } + // Issue #313: Check velocity threshold for alert and auto-end. + // The evaluator owns the working-rep guard so production and tests exercise + // the exact same decision seam. + evaluateLatestVbtResult() } } - private suspend fun checkVelocityThreshold() { + internal suspend fun evaluateLatestVbtResult() { + if (!coordinator._repCount.value.isWarmupComplete) return + val runtime = coordinator.vbtRuntimeSettings.value + if (!runtime.enabled) return val latestResult = coordinator.biomechanicsEngine.latestRepResult.value ?: return val velocity = latestResult.velocity @@ -1348,20 +1368,15 @@ class ActiveSessionEngine( coordinator._hapticEvents.emit(HapticEvent.VELOCITY_THRESHOLD_REACHED) Logger.i { "VBT: Velocity loss threshold reached (${velocity.velocityLossPercent?.roundToInt()}%). Alert emitted." } - // Issue #611: Verbal encouragement (gated on master + global audio toggle + vulgar) + // Issue #611: Verbal encouragement (gated on its master + global audio toggle). // Emitted alongside VELOCITY_THRESHOLD_REACHED so both events share one-shot - // semantics per set. The vulgar-on gate here is the master "encouragement fires" - // gate; the tier + dominatrix fields carry the routing info to the audio router. - val prefs = settingsManager.userPreferences.value - if (prefs.beepsEnabled && prefs.verbalEncouragementEnabled) { - coordinator._hapticEvents.emit( - HapticEvent.VERBAL_ENCOURAGEMENT( - vulgarTier = prefs.vulgarTier, - dominatrixMode = prefs.dominatrixModeActive, - vulgarMode = prefs.vulgarModeEnabled, - ), - ) - Logger.i { "VBT: VERBAL_ENCOURAGEMENT emitted (tier=${prefs.vulgarTier}, dominatrix=${prefs.dominatrixModeActive}, vulgar=${prefs.vulgarModeEnabled})" } + // semantics per set. Local adult confirmation neutralizes vulgar/dominatrix + // routing without rewriting the active profile's persisted intent. + val verbalEvent = settingsManager.userPreferences.value + .verbalEncouragementEventOrNull() + if (verbalEvent != null) { + coordinator._hapticEvents.emit(verbalEvent) + Logger.i { "VBT: VERBAL_ENCOURAGEMENT emitted (tier=${verbalEvent.vulgarTier}, dominatrix=${verbalEvent.dominatrixMode}, vulgar=${verbalEvent.vulgarMode})" } // Issue #649: when the user has VBT auto-end OFF, the verbal cue is // the only velocity signal — don't let AMRAP position / velocity-stall @@ -1378,7 +1393,7 @@ class ActiveSessionEngine( // Narrow the window for this fix by re-checking the active state // before writing; if the workout has already left Active (manual // stop / reset / next set started), drop the arm silently. - if (!coordinator.autoEndOnVelocityLoss && + if (!runtime.autoEndOnVelocityLoss && coordinator._workoutState.value is WorkoutState.Active ) { coordinator.deferAutoStopDeadlineMs = currentTimeMillis() + VERBAL_ENCOURAGEMENT_DEFER_WINDOW_MS @@ -1388,7 +1403,7 @@ class ActiveSessionEngine( } } - if (consecutiveThresholdReps >= 2 && coordinator.autoEndOnVelocityLoss) { + if (consecutiveThresholdReps >= 2 && runtime.autoEndOnVelocityLoss) { Logger.i { "VBT: Auto-ending set — $consecutiveThresholdReps consecutive reps above threshold" } handleSetCompletion() } @@ -1794,36 +1809,36 @@ class ActiveSessionEngine( } suspend fun getJustLiftDefaults(): JustLiftDefaults { - val prefsDefaults = preferencesManager.getJustLiftDefaults() - return JustLiftDefaults( - weightPerCableKg = prefsDefaults.weightPerCableKg, - weightChangePerRep = kotlin.math.round(prefsDefaults.weightChangePerRep).toInt(), - workoutModeId = prefsDefaults.workoutModeId, - eccentricLoadPercentage = prefsDefaults.eccentricLoadPercentage, - echoLevelValue = prefsDefaults.echoLevelValue, - stallDetectionEnabled = prefsDefaults.stallDetectionEnabled, - repCountTimingName = prefsDefaults.repCountTimingName, - restSeconds = prefsDefaults.restSeconds, - ) + return settingsManager.getJustLiftDefaultsDocument().toRuntimeJustLiftDefaults() } fun saveJustLiftDefaults(defaults: JustLiftDefaults) { - scope.launch { - val prefsDefaults = com.devil.phoenixproject.data.preferences.JustLiftDefaults( - weightPerCableKg = defaults.weightPerCableKg, - weightChangePerRep = defaults.weightChangePerRep.toFloat(), - workoutModeId = defaults.workoutModeId, - eccentricLoadPercentage = defaults.eccentricLoadPercentage, - echoLevelValue = defaults.echoLevelValue, - stallDetectionEnabled = defaults.stallDetectionEnabled, - repCountTimingName = defaults.repCountTimingName, - restSeconds = defaults.restSeconds, - ) - preferencesManager.saveJustLiftDefaults(prefsDefaults) - Logger.d("saveJustLiftDefaults: weight=${defaults.weightPerCableKg}kg, mode=${defaults.workoutModeId}, restSeconds=${defaults.restSeconds}") - } + settingsManager.saveJustLiftDefaultsDocument(defaults.toDocument()) + Logger.d("saveJustLiftDefaults: weight=${defaults.weightPerCableKg}kg, mode=${defaults.workoutModeId}, restSeconds=${defaults.restSeconds}") } + private fun JustLiftDefaults.toDocument() = JustLiftDefaultsDocument( + workoutModeId = workoutModeId, + weightPerCableKg = weightPerCableKg, + weightChangePerRep = weightChangePerRep.toFloat(), + eccentricLoadPercentage = eccentricLoadPercentage, + echoLevelValue = echoLevelValue, + stallDetectionEnabled = stallDetectionEnabled, + repCountTimingName = repCountTimingName, + restSeconds = restSeconds, + ) + + private fun JustLiftDefaultsDocument.toRuntimeJustLiftDefaults() = JustLiftDefaults( + workoutModeId = workoutModeId, + weightPerCableKg = weightPerCableKg, + weightChangePerRep = weightChangePerRep.roundToInt(), + eccentricLoadPercentage = eccentricLoadPercentage, + echoLevelValue = echoLevelValue, + stallDetectionEnabled = stallDetectionEnabled, + repCountTimingName = repCountTimingName, + restSeconds = restSeconds, + ) + private suspend fun saveJustLiftDefaultsFromWorkout() { val params = coordinator._workoutParameters.value if (!params.isJustLift) return @@ -1832,7 +1847,7 @@ class ActiveSessionEngine( val echoLevelVal = if (params.isEchoMode) params.echoLevel.levelValue else 0 try { - val defaults = com.devil.phoenixproject.data.preferences.JustLiftDefaults( + val defaults = JustLiftDefaultsDocument( workoutModeId = params.programMode.modeValue, weightPerCableKg = params.weightPerCableKg.coerceAtLeast(0.1f), weightChangePerRep = params.progressionRegressionKg, @@ -1842,20 +1857,21 @@ class ActiveSessionEngine( repCountTimingName = params.repCountTiming.name, restSeconds = params.justLiftRestSeconds, ) - preferencesManager.saveJustLiftDefaults(defaults) + settingsManager.saveJustLiftDefaultsDocument(defaults) Logger.d { "Saved Just Lift defaults: mode=${params.programMode.modeValue}, weight=${params.weightPerCableKg}kg, restSeconds=${params.justLiftRestSeconds}" } } catch (e: Exception) { Logger.e(e) { "Failed to save Just Lift defaults: ${e.message}" } } } - suspend fun getSingleExerciseDefaults(exerciseId: String): com.devil.phoenixproject.data.preferences.SingleExerciseDefaults? = preferencesManager.getSingleExerciseDefaults(exerciseId) + suspend fun getSingleExerciseDefaults( + exerciseId: String, + ): com.devil.phoenixproject.data.preferences.SingleExerciseDefaults? = + settingsManager.getSingleExerciseDefaultsDocument(exerciseId)?.toLegacySingleExerciseDefaults() fun saveSingleExerciseDefaults(defaults: com.devil.phoenixproject.data.preferences.SingleExerciseDefaults) { - scope.launch { - preferencesManager.saveSingleExerciseDefaults(defaults) - Logger.d("saveSingleExerciseDefaults: exerciseId=${defaults.exerciseId}") - } + settingsManager.saveSingleExerciseDefaultsDocument(defaults.toDocument()) + Logger.d("saveSingleExerciseDefaults: exerciseId=${defaults.exerciseId}") } private suspend fun saveSingleExerciseDefaultsFromWorkout() { @@ -1901,7 +1917,7 @@ class ActiveSessionEngine( perSetRestTime = currentExercise.perSetRestTime, defaultRackItemIds = currentExercise.defaultRackItemIds.filter { it.isNotBlank() }.distinct(), ) - preferencesManager.saveSingleExerciseDefaults(defaults) + settingsManager.saveSingleExerciseDefaultsDocument(defaults.toDocument()) Logger.d { "Saved Single Exercise defaults for ${currentExercise.exercise.name}" } } catch (e: IllegalArgumentException) { Logger.e(e) { "Failed to save Single Exercise defaults - validation error" } @@ -2530,6 +2546,10 @@ class ActiveSessionEngine( } fun startWorkout(skipCountdown: Boolean = false, isJustLiftMode: Boolean = false) { + if (userProfileRepository.activeProfileContext.value !is ActiveProfileContext.Ready) { + Logger.w { "Workout start ignored while profile context is switching" } + return + } Logger.d { "startWorkout called: skipCountdown=$skipCountdown, isJustLiftMode=$isJustLiftMode" } Logger.d { "startWorkout: loadedRoutine=${coordinator._loadedRoutine.value?.name}, params=${coordinator._workoutParameters.value}" } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/BleConnectionManager.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/BleConnectionManager.kt index 34e816d78..c3067f266 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/BleConnectionManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/BleConnectionManager.kt @@ -13,8 +13,11 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull @@ -82,6 +85,30 @@ class BleConnectionManager( } } + scope.launch { + try { + combine( + bleRepository.connectionState, + settingsManager.userPreferences.map { it.colorScheme }, + ) { connection, color -> + (connection is ConnectionState.Connected) to color + } + .distinctUntilChanged() + .collect { (connected, color) -> + bleRepository.setLastColorSchemeIndex(color) + if (connected) { + bleRepository.setColorScheme(color).onFailure { error -> + Logger.e(error) { "Failed to apply active profile LED color" } + } + } + } + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + Logger.e(error) { "Error collecting active profile LED color" } + } + } + // Connection state observer for detecting connection loss during workout (Issue #42) // When connection is lost during an active workout, show the ConnectionLostDialog // Moved from MainViewModel init block @@ -94,14 +121,6 @@ class BleConnectionManager( wasConnected = true // Clear any previous connection lost alert when reconnected _connectionLostDuringWorkout.value = false - // Issue #179: Initialize LED color scheme on connection - try { - val savedColorScheme = settingsManager.userPreferences.value.colorScheme - bleRepository.setColorScheme(savedColorScheme) - Logger.d { "Initialized LED color scheme to saved preference: $savedColorScheme" } - } catch (e: Exception) { - Logger.e(e) { "Failed to initialize LED color scheme on connection" } - } } is ConnectionState.Disconnected, is ConnectionState.Error -> { diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/DefaultWorkoutSessionManager.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/DefaultWorkoutSessionManager.kt index f4ab11446..59ddd4b7f 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/DefaultWorkoutSessionManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/DefaultWorkoutSessionManager.kt @@ -52,6 +52,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch // ===== Data classes that move with DefaultWorkoutSessionManager ===== @@ -194,9 +195,10 @@ class DefaultWorkoutSessionManager( // ===== Coordinator: Shared state bus for all workout state ===== val coordinator = run { - val prefs = preferencesManager.preferencesFlow.value + val prefs = settingsManager.userPreferences.value WorkoutCoordinator( _hapticEvents = _hapticEvents, + vbtEnabled = prefs.vbtEnabled, velocityLossThresholdPercent = prefs.velocityLossThresholdPercent.toFloat(), autoEndOnVelocityLoss = prefs.autoEndOnVelocityLoss, ) @@ -307,12 +309,24 @@ class DefaultWorkoutSessionManager( scope.launch { try { - preferencesManager.preferencesFlow.collect { prefs -> - coordinator.updateVbtSettings( - velocityLossThresholdPercent = prefs.velocityLossThresholdPercent.toFloat(), - autoEndOnVelocityLoss = prefs.autoEndOnVelocityLoss, - ) - } + settingsManager.userPreferences + .map { prefs -> + VbtRuntimeSettings( + enabled = prefs.vbtEnabled, + velocityLossThresholdPercent = prefs.velocityLossThresholdPercent.toFloat(), + autoEndOnVelocityLoss = prefs.autoEndOnVelocityLoss, + ) + } + .distinctUntilChanged() + .collect { runtime -> + coordinator.updateVbtSettings( + vbtEnabled = runtime.enabled, + thresholdPercent = runtime.velocityLossThresholdPercent, + autoEnd = runtime.autoEndOnVelocityLoss, + ) + } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Logger.e(e) { "Error in VBT settings collector" } } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/SettingsManager.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/SettingsManager.kt index 10c02de97..a4678da68 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/SettingsManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/SettingsManager.kt @@ -2,242 +2,406 @@ package com.devil.phoenixproject.presentation.manager import co.touchlab.kermit.Logger import com.devil.phoenixproject.data.preferences.PreferencesManager -import com.devil.phoenixproject.data.repository.BleRepository +import com.devil.phoenixproject.data.repository.ActiveProfileContext +import com.devil.phoenixproject.data.repository.ProfileContextUnavailableException +import com.devil.phoenixproject.data.repository.StaleProfileContextException +import com.devil.phoenixproject.data.repository.UserProfileRepository import com.devil.phoenixproject.domain.model.BleCompatibilitySetting +import com.devil.phoenixproject.domain.model.CoreProfilePreferences +import com.devil.phoenixproject.domain.model.JustLiftDefaultsDocument +import com.devil.phoenixproject.domain.model.LedPreferences +import com.devil.phoenixproject.domain.model.ProfileLocalSafetyPreferences import com.devil.phoenixproject.domain.model.RepCountTiming import com.devil.phoenixproject.domain.model.ScalingBasis +import com.devil.phoenixproject.domain.model.SingleExerciseDefaultsDocument import com.devil.phoenixproject.domain.model.UserPreferences +import com.devil.phoenixproject.domain.model.VbtPreferences import com.devil.phoenixproject.domain.model.VulgarTier import com.devil.phoenixproject.domain.model.WeightUnit +import com.devil.phoenixproject.domain.model.WorkoutPreferences +import com.devil.phoenixproject.util.BackupDestination import com.devil.phoenixproject.util.format +import kotlin.coroutines.cancellation.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock /** - * Manages user settings and preference-derived state flows. - * Extracted from MainViewModel during monolith decomposition. + * Compatibility facade that combines global application preferences with the + * active profile's training preferences. */ class SettingsManager( - private val preferencesManager: PreferencesManager, - private val bleRepository: BleRepository, + private val globalPreferences: PreferencesManager, + private val userProfileRepository: UserProfileRepository, private val scope: CoroutineScope, ) { - val userPreferences: StateFlow = preferencesManager.preferencesFlow - .stateIn(scope, SharingStarted.Eagerly, UserPreferences()) + private val coreUpdates = Mutex() + private val workoutUpdates = Mutex() + private val ledUpdates = Mutex() + private val vbtAndSafetyUpdates = Mutex() + + private fun overlayProfile( + global: UserPreferences, + ready: ActiveProfileContext.Ready, + ): UserPreferences { + val core = ready.preferences.core.value + val workout = ready.preferences.workout.value + val led = ready.preferences.led.value + val vbt = ready.preferences.vbt.value + val safety = ready.localSafety + return global.copy( + weightUnit = core.weightUnit, + weightIncrement = core.weightIncrement, + bodyWeightKg = core.bodyWeightKg, + stopAtTop = workout.stopAtTop, + beepsEnabled = workout.beepsEnabled, + audioRepCountEnabled = workout.audioRepCountEnabled, + repCountTiming = workout.repCountTiming, + summaryCountdownSeconds = workout.summaryCountdownSeconds, + autoStartCountdownSeconds = workout.autoStartCountdownSeconds, + gamificationEnabled = workout.gamificationEnabled, + autoStartRoutine = workout.autoStartRoutine, + countdownBeepsEnabled = workout.countdownBeepsEnabled, + repSoundEnabled = workout.repSoundEnabled, + motionStartEnabled = workout.motionStartEnabled, + weightSuggestionsEnabled = workout.weightSuggestionsEnabled, + defaultRoutineExerciseUsePercentOfPR = workout.defaultRoutineExerciseUsePercentOfPR, + defaultRoutineExerciseWeightPercentOfPR = workout.defaultRoutineExerciseWeightPercentOfPR, + voiceStopEnabled = workout.voiceStopEnabled, + safeWord = safety.safeWord, + safeWordCalibrated = safety.safeWordCalibrated, + colorScheme = led.colorScheme, + discoModeUnlocked = led.discoModeUnlocked, + vbtEnabled = vbt.enabled, + velocityLossThresholdPercent = vbt.velocityLossThresholdPercent, + autoEndOnVelocityLoss = vbt.autoEndOnVelocityLoss, + defaultScalingBasis = vbt.defaultScalingBasis, + verbalEncouragementEnabled = vbt.verbalEncouragementEnabled, + vulgarModeEnabled = vbt.vulgarModeEnabled, + vulgarTier = vbt.vulgarTier, + dominatrixModeUnlocked = vbt.dominatrixModeUnlocked, + dominatrixModeActive = vbt.dominatrixModeActive, + adultsOnlyConfirmed = safety.adultsOnlyConfirmed, + adultsOnlyPrompted = safety.adultsOnlyPrompted, + ) + } + + private fun initialUserPreferences(): UserPreferences { + val global = globalPreferences.preferencesFlow.value + val ready = userProfileRepository.activeProfileContext.value as? ActiveProfileContext.Ready + ?: return global + return overlayProfile(global, ready) + } + + val userPreferences: StateFlow = combine( + globalPreferences.preferencesFlow, + userProfileRepository.activeProfileContext.filterIsInstance(), + ) { global, ready -> + overlayProfile(global, ready) + }.stateIn(scope, SharingStarted.Eagerly, initialUserPreferences()) val weightUnit: StateFlow = userPreferences .map { it.weightUnit } - .stateIn(scope, SharingStarted.Eagerly, WeightUnit.KG) + .stateIn(scope, SharingStarted.Eagerly, userPreferences.value.weightUnit) val enableVideoPlayback: StateFlow = userPreferences .map { it.enableVideoPlayback } - .stateIn(scope, SharingStarted.Eagerly, true) + .stateIn(scope, SharingStarted.Eagerly, userPreferences.value.enableVideoPlayback) val gamificationEnabled: StateFlow = userPreferences .map { it.gamificationEnabled } - .stateIn(scope, SharingStarted.Eagerly, true) + .stateIn(scope, SharingStarted.Eagerly, userPreferences.value.gamificationEnabled) - // Issue #517: Default scaling basis for % of 1RM routine weight resolution val defaultScalingBasis: StateFlow = userPreferences .map { it.defaultScalingBasis } - .stateIn(scope, SharingStarted.Eagerly, ScalingBasis.MAX_WEIGHT_PR) + .stateIn(scope, SharingStarted.Eagerly, userPreferences.value.defaultScalingBasis) - // Issue #595: Routine-builder defaults for newly added cable exercises val defaultRoutineExerciseUsePercentOfPR: StateFlow = userPreferences .map { it.defaultRoutineExerciseUsePercentOfPR } - .stateIn(scope, SharingStarted.Eagerly, false) + .stateIn( + scope, + SharingStarted.Eagerly, + userPreferences.value.defaultRoutineExerciseUsePercentOfPR, + ) val defaultRoutineExerciseWeightPercentOfPR: StateFlow = userPreferences .map { it.defaultRoutineExerciseWeightPercentOfPR } - .stateIn(scope, SharingStarted.Eagerly, 80) + .stateIn( + scope, + SharingStarted.Eagerly, + userPreferences.value.defaultRoutineExerciseWeightPercentOfPR, + ) - // Issue #517: Run-once flag — true after the velocity 1RM backfill has completed val velocityOneRepMaxBackfillDone: StateFlow = userPreferences .map { it.velocityOneRepMaxBackfillDone } - .stateIn(scope, SharingStarted.Eagerly, false) + .stateIn(scope, SharingStarted.Eagerly, userPreferences.value.velocityOneRepMaxBackfillDone) - // Issue #167: Autoplay is now derived from summaryCountdownSeconds - // - summaryCountdownSeconds == 0 (Unlimited) = autoplay OFF (manual control) - // - summaryCountdownSeconds != 0 (-1 or 5-30) = autoplay ON (auto-advance) val autoplayEnabled: StateFlow = userPreferences .map { it.summaryCountdownSeconds != 0 } - .stateIn(scope, SharingStarted.Eagerly, true) + .stateIn(scope, SharingStarted.Eagerly, userPreferences.value.summaryCountdownSeconds != 0) - // Issue #333: BLE small-MTU compatibility path (Auto = on for Pixel 6/7 family) val bleCompatibilityMode: StateFlow = userPreferences .map { it.bleCompatibilityMode } - .stateIn(scope, SharingStarted.Eagerly, BleCompatibilitySetting.AUTO) + .stateIn(scope, SharingStarted.Eagerly, userPreferences.value.bleCompatibilityMode) - fun setWeightUnit(unit: WeightUnit) { - scope.launch { preferencesManager.setWeightUnit(unit) } - } - - fun setStopAtTop(enabled: Boolean) { - scope.launch { preferencesManager.setStopAtTop(enabled) } - } + private fun ready(): ActiveProfileContext.Ready = + userProfileRepository.activeProfileContext.value as? ActiveProfileContext.Ready + ?: throw ProfileContextUnavailableException() - fun setEnableVideoPlayback(enabled: Boolean) { - scope.launch { preferencesManager.setEnableVideoPlayback(enabled) } - } - - fun setAudioRepCountEnabled(enabled: Boolean) { - scope.launch { preferencesManager.setAudioRepCountEnabled(enabled) } + private fun readyFor(expectedId: String): ActiveProfileContext.Ready { + val current = ready() + if (current.profile.id != expectedId) { + throw StaleProfileContextException(expectedId, current.profile.id) + } + return current + } + + private fun updateSection( + mutex: Mutex, + read: (ActiveProfileContext.Ready) -> T, + write: suspend (String, T) -> Unit, + transform: (ActiveProfileContext.Ready, T) -> T?, + ) { + val expectedId = (userProfileRepository.activeProfileContext.value as? ActiveProfileContext.Ready) + ?.profile?.id + ?: run { + Logger.w { "Profile preference update ignored while switching" } + return + } + scope.launch { + try { + mutex.withLock { + val current = readyFor(expectedId) + val next = transform(current, read(current)) ?: return@withLock + write(expectedId, next) + } + } catch (error: CancellationException) { + throw error + } catch (error: ProfileContextUnavailableException) { + Logger.w(error) { "Profile preference update skipped while switching" } + } catch (error: StaleProfileContextException) { + Logger.w(error) { "Profile preference update skipped after profile switch" } + } + } } - fun setRepCountTiming(timing: RepCountTiming) { - scope.launch { preferencesManager.setRepCountTiming(timing) } + private fun updateCore(transform: (CoreProfilePreferences) -> CoreProfilePreferences) { + val expectedId = (userProfileRepository.activeProfileContext.value as? ActiveProfileContext.Ready) + ?.profile?.id + ?: run { + Logger.w { "Profile preference update ignored while switching" } + return + } + scope.launch { + try { + coreUpdates.withLock { + userProfileRepository.mutateCore(expectedId, transform) + } + } catch (error: CancellationException) { + throw error + } catch (error: ProfileContextUnavailableException) { + Logger.w(error) { "Profile preference update skipped while switching" } + } catch (error: StaleProfileContextException) { + Logger.w(error) { "Profile preference update skipped after profile switch" } + } + } } + private fun updateWorkout(transform: (WorkoutPreferences) -> WorkoutPreferences) = + updateSection( + mutex = workoutUpdates, + read = { it.preferences.workout.value }, + write = userProfileRepository::updateWorkout, + ) { _, value -> transform(value) } + + private fun updateLed(transform: (LedPreferences) -> LedPreferences) = + updateSection( + mutex = ledUpdates, + read = { it.preferences.led.value }, + write = userProfileRepository::updateLed, + ) { _, value -> transform(value) } + + private fun updateVbt( + transform: (ActiveProfileContext.Ready, VbtPreferences) -> VbtPreferences?, + ) = updateSection( + mutex = vbtAndSafetyUpdates, + read = { it.preferences.vbt.value }, + write = userProfileRepository::updateVbt, + transform = transform, + ) + + private fun updateSafety( + transform: (ActiveProfileContext.Ready, ProfileLocalSafetyPreferences) -> ProfileLocalSafetyPreferences, + ) = updateSection( + mutex = vbtAndSafetyUpdates, + read = { it.localSafety }, + write = userProfileRepository::updateLocalSafety, + transform = transform, + ) + + fun setWeightUnit(unit: WeightUnit) = updateCore { it.copy(weightUnit = unit) } + fun setWeightIncrement(increment: Float) = updateCore { it.copy(weightIncrement = increment) } + fun setBodyWeightKg(weightKg: Float) = updateCore { it.copy(bodyWeightKg = weightKg) } + + fun setStopAtTop(enabled: Boolean) = updateWorkout { it.copy(stopAtTop = enabled) } + fun setBeepsEnabled(enabled: Boolean) = updateWorkout { it.copy(beepsEnabled = enabled) } + fun setStallDetectionEnabled(enabled: Boolean) = updateWorkout { it.copy(stallDetectionEnabled = enabled) } + fun setAudioRepCountEnabled(enabled: Boolean) = updateWorkout { it.copy(audioRepCountEnabled = enabled) } + fun setRepCountTiming(timing: RepCountTiming) = updateWorkout { it.copy(repCountTiming = timing) } fun setSummaryCountdownSeconds(seconds: Int) { Logger.d("setSummaryCountdownSeconds: Setting value to $seconds") - scope.launch { preferencesManager.setSummaryCountdownSeconds(seconds) } - } - - fun setAutoStartCountdownSeconds(seconds: Int) { - scope.launch { preferencesManager.setAutoStartCountdownSeconds(seconds) } - } - - fun setWeightIncrement(increment: Float) { - scope.launch { preferencesManager.setWeightIncrement(increment) } - } - - fun setAutoStartRoutine(enabled: Boolean) { - scope.launch { preferencesManager.setAutoStartRoutine(enabled) } - } - - fun setBodyWeightKg(weightKg: Float) { - scope.launch { preferencesManager.setBodyWeightKg(weightKg) } - } - - fun setGamificationEnabled(enabled: Boolean) { - scope.launch { preferencesManager.setGamificationEnabled(enabled) } + updateWorkout { it.copy(summaryCountdownSeconds = seconds) } + } + fun setAutoStartCountdownSeconds(seconds: Int) = updateWorkout { it.copy(autoStartCountdownSeconds = seconds) } + fun setGamificationEnabled(enabled: Boolean) = updateWorkout { it.copy(gamificationEnabled = enabled) } + fun setAutoStartRoutine(enabled: Boolean) = updateWorkout { it.copy(autoStartRoutine = enabled) } + fun setCountdownBeepsEnabled(enabled: Boolean) = updateWorkout { it.copy(countdownBeepsEnabled = enabled) } + fun setRepSoundEnabled(enabled: Boolean) = updateWorkout { it.copy(repSoundEnabled = enabled) } + fun setMotionStartEnabled(enabled: Boolean) = updateWorkout { it.copy(motionStartEnabled = enabled) } + fun setWeightSuggestionsEnabled(enabled: Boolean) = updateWorkout { it.copy(weightSuggestionsEnabled = enabled) } + fun setDefaultRoutineExerciseUsePercentOfPR(enabled: Boolean) = + updateWorkout { it.copy(defaultRoutineExerciseUsePercentOfPR = enabled) } + fun setDefaultRoutineExerciseWeightPercentOfPR(percent: Int) = + updateWorkout { it.copy(defaultRoutineExerciseWeightPercentOfPR = percent.coerceIn(50, 120)) } + fun setVoiceStopEnabled(enabled: Boolean) = updateWorkout { it.copy(voiceStopEnabled = enabled) } + + fun setColorScheme(schemeIndex: Int) = updateLed { it.copy(colorScheme = schemeIndex) } + fun setDiscoModeUnlocked(unlocked: Boolean) = updateLed { it.copy(discoModeUnlocked = unlocked) } + + fun setVbtEnabled(enabled: Boolean) = updateVbt { _, current -> current.copy(enabled = enabled) } + fun setVelocityLossThreshold(percent: Int) = updateVbt { _, current -> + current.copy(velocityLossThresholdPercent = percent.coerceIn(10, 50)) + } + fun setAutoEndOnVelocityLoss(enabled: Boolean) = updateVbt { _, current -> + current.copy(autoEndOnVelocityLoss = enabled) + } + fun setDefaultScalingBasis(basis: ScalingBasis) = updateVbt { _, current -> + current.copy(defaultScalingBasis = basis) + } + fun setVerbalEncouragementEnabled(enabled: Boolean) = updateVbt { _, current -> + if (enabled) { + current.copy(verbalEncouragementEnabled = true) + } else { + current.copy( + verbalEncouragementEnabled = false, + vulgarModeEnabled = false, + dominatrixModeActive = false, + ) + } } - - // Issue #333: BLE small-MTU compatibility path (Auto/On/Off) - fun setBleCompatibilityMode(setting: BleCompatibilitySetting) { - scope.launch { preferencesManager.setBleCompatibilityMode(setting) } + fun setVulgarModeEnabled(enabled: Boolean) = updateVbt { context, current -> + when { + enabled && !context.localSafety.adultsOnlyPrompted -> null + !enabled -> current.copy(vulgarModeEnabled = false, dominatrixModeActive = false) + else -> current.copy(vulgarModeEnabled = true) + } } - - fun setCountdownBeepsEnabled(enabled: Boolean) { - scope.launch { preferencesManager.setCountdownBeepsEnabled(enabled) } + fun setVulgarTier(tier: VulgarTier) = updateVbt { _, current -> current.copy(vulgarTier = tier) } + fun setDominatrixModeUnlocked(unlocked: Boolean) = updateVbt { _, current -> + current.copy(dominatrixModeUnlocked = unlocked) } - - fun setRepSoundEnabled(enabled: Boolean) { - scope.launch { preferencesManager.setRepSoundEnabled(enabled) } + fun setDominatrixModeActive(active: Boolean) = updateVbt { context, current -> + if ( + active && + (!current.dominatrixModeUnlocked || !current.vulgarModeEnabled || !context.localSafety.adultsOnlyConfirmed) + ) { + null + } else { + current.copy(dominatrixModeActive = active) + } } - fun setMotionStartEnabled(enabled: Boolean) { - scope.launch { preferencesManager.setMotionStartEnabled(enabled) } + fun setSafeWord(word: String?) = updateSafety { _, current -> current.copy(safeWord = word) } + fun setSafeWordCalibrated(calibrated: Boolean) = updateSafety { _, current -> + current.copy(safeWordCalibrated = calibrated) } - - fun setAutoBackupEnabled(enabled: Boolean) { - scope.launch { preferencesManager.setAutoBackupEnabled(enabled) } + fun setAdultsOnlyConfirmed(confirmed: Boolean) = updateSafety { _, current -> + current.copy(adultsOnlyConfirmed = confirmed, adultsOnlyPrompted = true) } - - fun setBackupDestination(destination: com.devil.phoenixproject.util.BackupDestination) { - scope.launch { preferencesManager.setBackupDestination(destination) } + fun isAdultsOnlyPrompted(): Boolean = ready().localSafety.adultsOnlyPrompted + fun setAdultsOnlyPrompted(prompted: Boolean) = updateSafety { _, current -> + current.copy(adultsOnlyPrompted = prompted) } - fun setLanguage(language: String) { - scope.launch { preferencesManager.setLanguage(language) } - // Apply locale to the platform so the UI updates immediately (Android) - // or on next launch (iOS). The preference is persisted above for cold starts. - com.devil.phoenixproject.util.applyAppLocale(language) + fun confirmAdultsAndEnableVulgar() { + val expectedId = (userProfileRepository.activeProfileContext.value as? ActiveProfileContext.Ready) + ?.profile?.id ?: return + scope.launch { + try { + vbtAndSafetyUpdates.withLock { + val before = readyFor(expectedId) + userProfileRepository.updateLocalSafety( + expectedId, + before.localSafety.copy( + adultsOnlyConfirmed = true, + adultsOnlyPrompted = true, + ), + ) + val afterSafety = readyFor(expectedId) + userProfileRepository.updateVbt( + expectedId, + afterSafety.preferences.vbt.value.copy(vulgarModeEnabled = true), + ) + } + } catch (error: CancellationException) { + throw error + } catch (error: ProfileContextUnavailableException) { + Logger.w(error) { "Adult consent update stopped while switching" } + } catch (error: StaleProfileContextException) { + Logger.w(error) { "Adult consent update stopped after profile switch" } + } + } } - // Issue #141: Voice-activated emergency stop - fun setVoiceStopEnabled(enabled: Boolean) { - scope.launch { preferencesManager.setVoiceStopEnabled(enabled) } - } + fun getSingleExerciseDefaultsDocument(exerciseId: String): SingleExerciseDefaultsDocument? = + ready().preferences.workout.value.singleExerciseDefaults[exerciseId] - fun setSafeWord(word: String?) { - scope.launch { preferencesManager.setSafeWord(word) } - } + fun saveSingleExerciseDefaultsDocument(document: SingleExerciseDefaultsDocument) = + updateWorkout { current -> + current.copy( + singleExerciseDefaults = current.singleExerciseDefaults + + (document.exerciseId to document), + ) + } - fun setSafeWordCalibrated(calibrated: Boolean) { - scope.launch { preferencesManager.setSafeWordCalibrated(calibrated) } - } + fun getJustLiftDefaultsDocument(): JustLiftDefaultsDocument = + ready().preferences.workout.value.justLiftDefaults - fun setVelocityLossThreshold(percent: Int) { - scope.launch { preferencesManager.setVelocityLossThreshold(percent) } - } + fun saveJustLiftDefaultsDocument(document: JustLiftDefaultsDocument) = + updateWorkout { current -> current.copy(justLiftDefaults = document) } - fun setAutoEndOnVelocityLoss(enabled: Boolean) { - scope.launch { preferencesManager.setAutoEndOnVelocityLoss(enabled) } + fun setEnableVideoPlayback(enabled: Boolean) { + scope.launch { globalPreferences.setEnableVideoPlayback(enabled) } } - fun setWeightSuggestionsEnabled(enabled: Boolean) { - scope.launch { preferencesManager.setWeightSuggestionsEnabled(enabled) } + fun setAutoBackupEnabled(enabled: Boolean) { + scope.launch { globalPreferences.setAutoBackupEnabled(enabled) } } - fun setDefaultScalingBasis(basis: ScalingBasis) { - scope.launch { preferencesManager.setDefaultScalingBasis(basis) } + fun setBackupDestination(destination: BackupDestination) { + scope.launch { globalPreferences.setBackupDestination(destination) } } - fun setDefaultRoutineExerciseUsePercentOfPR(enabled: Boolean) { - scope.launch { preferencesManager.setDefaultRoutineExerciseUsePercentOfPR(enabled) } + fun setLanguage(language: String) { + scope.launch { globalPreferences.setLanguage(language) } + com.devil.phoenixproject.util.applyAppLocale(language) } - fun setDefaultRoutineExerciseWeightPercentOfPR(percent: Int) { - scope.launch { preferencesManager.setDefaultRoutineExerciseWeightPercentOfPR(percent.coerceIn(50, 120)) } + fun setBleCompatibilityMode(setting: BleCompatibilitySetting) { + scope.launch { globalPreferences.setBleCompatibilityMode(setting) } } fun setVelocityOneRepMaxBackfillDone(done: Boolean) { - scope.launch { preferencesManager.setVelocityOneRepMaxBackfillDone(done) } - } - - // Issue #611: Verbal encouragement + opt-in vulgar mode + Dominatrix mode + 18+ gate - fun setVerbalEncouragementEnabled(enabled: Boolean) { - scope.launch { preferencesManager.setVerbalEncouragementEnabled(enabled) } - } - - fun setVulgarModeEnabled(enabled: Boolean) { - scope.launch { preferencesManager.setVulgarModeEnabled(enabled) } - } - - fun setVulgarTier(tier: VulgarTier) { - scope.launch { preferencesManager.setVulgarTier(tier) } - } - - fun setDominatrixModeUnlocked(unlocked: Boolean) { - scope.launch { preferencesManager.setDominatrixModeUnlocked(unlocked) } - } - - fun setDominatrixModeActive(active: Boolean) { - scope.launch { preferencesManager.setDominatrixModeActive(active) } - } - - fun setAdultsOnlyConfirmed(confirmed: Boolean) { - scope.launch { preferencesManager.setAdultsOnlyConfirmed(confirmed) } - } - - // Issue #611 (PR-followup #613): One-shot decline-remember gate accessors for - // the 18+ Adults Only modal. Non-suspend so the modal-call site can read the - // gate on the main thread without dispatching into the preference scope. - fun isAdultsOnlyPrompted(): Boolean = preferencesManager.isAdultsOnlyPrompted() - - fun setAdultsOnlyPrompted(prompted: Boolean) { - // Synchronous write is acceptable — KEY_ADULTS_ONLY_PROMPTED is a single - // boolean backed by NSUserDefaults/SharedPreferences putBoolean which both - // platforms guarantee immediate visibility to subsequent reads. - preferencesManager.setAdultsOnlyPrompted(prompted) - } - - fun setColorScheme(schemeIndex: Int) { - scope.launch { - bleRepository.setColorScheme(schemeIndex) - preferencesManager.setColorScheme(schemeIndex) - // Update disco mode's restore color index (Issue #144: via interface method) - bleRepository.setLastColorSchemeIndex(schemeIndex) - } + scope.launch { globalPreferences.setVelocityOneRepMaxBackfillDone(done) } } - // Weight conversion functions — keep original signatures with explicit unit parameter - // to preserve backward compatibility with all call sites fun kgToDisplay(kg: Float, unit: WeightUnit): Float = when (unit) { WeightUnit.KG -> kg WeightUnit.LB -> kg * 2.20462f @@ -250,7 +414,6 @@ class SettingsManager( fun formatWeight(kg: Float, unit: WeightUnit): String { val value = kgToDisplay(kg, unit) - // Format with up to 2 decimals, trimming trailing zeros val formatted = if (value % 1 == 0f) { value.toInt().toString() } else { diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutCoordinator.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutCoordinator.kt index 2d980bc4e..e1e8a0043 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutCoordinator.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutCoordinator.kt @@ -33,6 +33,12 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +internal data class VbtRuntimeSettings( + val enabled: Boolean = true, + val velocityLossThresholdPercent: Float = 20f, + val autoEndOnVelocityLoss: Boolean = false, +) + /** * Shared state bus for all workout state. Contains zero business logic methods — * only state fields, public getters, and companion constants. @@ -48,7 +54,8 @@ class WorkoutCoordinator( extraBufferCapacity = 32, onBufferOverflow = BufferOverflow.SUSPEND, ), - private val velocityLossThresholdPercent: Float = 20f, + vbtEnabled: Boolean = true, + velocityLossThresholdPercent: Float = 20f, autoEndOnVelocityLoss: Boolean = false, ) { companion object { @@ -543,9 +550,18 @@ class WorkoutCoordinator( // ===== Biomechanics Engine ===== - private val _autoEndOnVelocityLoss = MutableStateFlow(autoEndOnVelocityLoss) + private val _vbtRuntimeSettings = MutableStateFlow( + VbtRuntimeSettings( + enabled = vbtEnabled, + velocityLossThresholdPercent = velocityLossThresholdPercent, + autoEndOnVelocityLoss = autoEndOnVelocityLoss, + ), + ) + internal val vbtRuntimeSettings: StateFlow = + _vbtRuntimeSettings.asStateFlow() + internal val autoEndOnVelocityLoss: Boolean - get() = _autoEndOnVelocityLoss.value + get() = _vbtRuntimeSettings.value.autoEndOnVelocityLoss /** * Biomechanics analysis engine for VBT, force curve, and asymmetry analysis. @@ -555,11 +571,17 @@ class WorkoutCoordinator( val biomechanicsEngine = BiomechanicsEngine(velocityLossThresholdPercent) internal fun updateVbtSettings( - velocityLossThresholdPercent: Float, - autoEndOnVelocityLoss: Boolean, + vbtEnabled: Boolean, + thresholdPercent: Float, + autoEnd: Boolean, ) { - biomechanicsEngine.updateVelocityLossThresholdPercent(velocityLossThresholdPercent) - _autoEndOnVelocityLoss.value = autoEndOnVelocityLoss + require(thresholdPercent in 10f..50f) + biomechanicsEngine.updateVelocityLossThresholdPercent(thresholdPercent) + _vbtRuntimeSettings.value = VbtRuntimeSettings( + enabled = vbtEnabled, + velocityLossThresholdPercent = thresholdPercent, + autoEndOnVelocityLoss = autoEnd, + ) } /** diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/navigation/NavGraph.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/navigation/NavGraph.kt index eb60e4d7d..4ad02bd28 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/navigation/NavGraph.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/navigation/NavGraph.kt @@ -25,18 +25,93 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.navArgument import androidx.savedstate.read +import com.devil.phoenixproject.data.repository.ActiveProfileContext import com.devil.phoenixproject.data.repository.ExerciseRepository +import com.devil.phoenixproject.data.repository.ProfileContextRecoveryException import com.devil.phoenixproject.data.repository.TrainingCycleRepository +import com.devil.phoenixproject.data.repository.UserProfileRepository import com.devil.phoenixproject.domain.model.TrainingCycle +import com.devil.phoenixproject.domain.model.ConnectionState +import com.devil.phoenixproject.domain.model.WorkoutMetric import com.devil.phoenixproject.presentation.screen.* import com.devil.phoenixproject.presentation.viewmodel.AssessmentViewModel import com.devil.phoenixproject.presentation.viewmodel.MainViewModel import com.devil.phoenixproject.ui.theme.ThemeMode -import com.devil.phoenixproject.util.BackupDestination +import kotlinx.coroutines.flow.StateFlow import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel import vitruvianprojectphoenix.shared.generated.resources.Res import vitruvianprojectphoenix.shared.generated.resources.insights_title +import vitruvianprojectphoenix.shared.generated.resources.nav_profile + +internal sealed interface AssessmentProfileDestinationState { + data class Bound(val profileId: String) : AssessmentProfileDestinationState + + data object Invalidated : AssessmentProfileDestinationState +} + +internal fun resolveAssessmentProfileDestination( + routeProfileId: String, + context: ActiveProfileContext, +): AssessmentProfileDestinationState = if ( + routeProfileId.isNotBlank() && + context is ActiveProfileContext.Ready && + context.profile.id == routeProfileId +) { + AssessmentProfileDestinationState.Bound(routeProfileId) +} else { + AssessmentProfileDestinationState.Invalidated +} + +@Composable +private fun AssessmentDestination( + routeProfileId: String, + exerciseId: String?, + themeMode: ThemeMode, + metricsFlow: StateFlow, + onNavigateBack: () -> Unit, +) { + val profileRepository: UserProfileRepository = koinInject() + val activeProfileContext by profileRepository.activeProfileContext.collectAsState() + val destinationState = resolveAssessmentProfileDestination( + routeProfileId = routeProfileId, + context = activeProfileContext, + ) + LaunchedEffect(destinationState) { + if (destinationState == AssessmentProfileDestinationState.Invalidated) { + onNavigateBack() + } + } + + when (destinationState) { + is AssessmentProfileDestinationState.Bound -> { + val assessmentViewModel: AssessmentViewModel = koinViewModel() + AssessmentWizardScreen( + viewModel = assessmentViewModel, + profileId = destinationState.profileId, + exerciseId = exerciseId, + themeMode = themeMode, + onNavigateBack = onNavigateBack, + metricsFlow = metricsFlow, + ) + } + + AssessmentProfileDestinationState.Invalidated -> Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + LoadingIndicator(LoadingIndicatorSize.Large) + } + } +} + +@Composable +private fun readyAssessmentProfileId(): String? { + val profileRepository: UserProfileRepository = koinInject() + val activeProfileContext by profileRepository.activeProfileContext.collectAsState() + return (activeProfileContext as? ActiveProfileContext.Ready)?.profile?.id +} /** * Main navigation graph for the app. @@ -53,6 +128,8 @@ fun NavGraph( dynamicColorAvailable: Boolean, dynamicColorEnabled: Boolean, onDynamicColorEnabledChange: (Boolean) -> Unit, + onOpenProfileSwitcher: () -> Unit, + onProfileRecoveryRequired: (ProfileContextRecoveryException) -> Unit, modifier: Modifier = Modifier, ) { SharedTransitionLayout { @@ -244,11 +321,15 @@ fun NavGraph( popEnterTransition = { NavTransitions.tabFadeEnter() }, popExitTransition = { NavTransitions.tabFadeExit() }, ) { + val assessmentProfileId = readyAssessmentProfileId() AnalyticsScreen( viewModel = viewModel, themeMode = themeMode, - onNavigateToStrengthAssessment = { - navController.navigate(NavigationRoutes.StrengthAssessmentPicker.route) + assessmentProfileId = assessmentProfileId, + onNavigateToStrengthAssessment = { profileId -> + navController.navigate( + NavigationRoutes.StrengthAssessmentPicker.createRoute(profileId), + ) }, ) } @@ -270,6 +351,44 @@ fun NavGraph( SmartInsightsTab() } + composable( + route = NavigationRoutes.Profile.route, + enterTransition = { NavTransitions.tabFadeEnter() }, + exitTransition = { NavTransitions.tabFadeExit() }, + popEnterTransition = { NavTransitions.tabFadeEnter() }, + popExitTransition = { NavTransitions.tabFadeExit() }, + ) { + val profileTitle = stringResource(Res.string.nav_profile) + val userPreferences by viewModel.userPreferences.collectAsState() + val connectionState by viewModel.connectionState.collectAsState() + val discoModeActive by viewModel.discoModeActive.collectAsState() + LaunchedEffect(profileTitle) { + viewModel.updateTopBarTitle(profileTitle) + } + ProfileScreen( + onOpenProfileSwitcher = onOpenProfileSwitcher, + onNavigateToExerciseDetail = { exerciseId -> + navController.navigate( + NavigationRoutes.ExerciseDetail.createRoute(exerciseId), + ) + }, + onNavigateToEquipmentRack = { + navController.navigate(NavigationRoutes.EquipmentRack.route) + }, + onNavigateToBadges = { + navController.navigate(NavigationRoutes.Badges.route) + }, + onProfileRecoveryRequired = onProfileRecoveryRequired, + isConnected = connectionState is ConnectionState.Connected, + discoModeActive = discoModeActive, + onDiscoModeToggle = viewModel::toggleDiscoMode, + onPlayDiscoUnlockSound = viewModel::emitDiscoSound, + onPlayDominatrixUnlockSound = viewModel::emitDominatrixUnlockSound, + enableVideoPlayback = userPreferences.enableVideoPlayback, + themeMode = themeMode, + ) + } + // Exercise Detail screen - drill-down for individual exercise composable( route = NavigationRoutes.ExerciseDetail.route, @@ -309,11 +428,18 @@ fun NavGraph( return@composable } + val assessmentProfileId = readyAssessmentProfileId() ExerciseDetailScreen( exerciseId = exerciseId, navController = navController, viewModel = viewModel, themeMode = themeMode, + assessmentProfileId = assessmentProfileId, + onNavigateToStrengthAssessment = { profileId -> + navController.navigate( + NavigationRoutes.StrengthAssessment.createRoute(profileId, exerciseId), + ) + }, ) } @@ -325,122 +451,46 @@ fun NavGraph( popEnterTransition = { NavTransitions.tabFadeEnter() }, popExitTransition = { NavTransitions.tabFadeExit() }, ) { - val weightUnit by viewModel.weightUnit.collectAsState() - val userPreferences by viewModel.userPreferences.collectAsState() + val globalSettings by viewModel.globalSettings.collectAsState() val connectionError by viewModel.connectionError.collectAsState() - val connectionState by viewModel.connectionState.collectAsState() - val discoModeActive by viewModel.discoModeActive.collectAsState() val backupStats by viewModel.backupStats.collectAsState() // Refresh backup stats when Settings screen is displayed - androidx.compose.runtime.LaunchedEffect(Unit) { viewModel.refreshBackupStats() } + LaunchedEffect(Unit) { viewModel.refreshBackupStats() } SettingsTab( - weightUnit = weightUnit, - enableVideoPlayback = userPreferences.enableVideoPlayback, + enableVideoPlayback = globalSettings.enableVideoPlayback, themeMode = themeMode, dynamicColorAvailable = dynamicColorAvailable, dynamicColorEnabled = dynamicColorEnabled, - audioRepCountEnabled = userPreferences.audioRepCountEnabled, - countdownBeepsEnabled = userPreferences.countdownBeepsEnabled, - repSoundEnabled = userPreferences.repSoundEnabled, - onCountdownBeepsChange = { viewModel.setCountdownBeepsEnabled(it) }, - onRepSoundChange = { viewModel.setRepSoundEnabled(it) }, - motionStartEnabled = userPreferences.motionStartEnabled, - onMotionStartChange = { viewModel.setMotionStartEnabled(it) }, - autoStartRoutine = userPreferences.autoStartRoutine, - onAutoStartRoutineChange = { viewModel.setAutoStartRoutine(it) }, - weightSuggestionsEnabled = userPreferences.weightSuggestionsEnabled, - onWeightSuggestionsEnabledChange = { viewModel.setWeightSuggestionsEnabled(it) }, - summaryCountdownSeconds = userPreferences.summaryCountdownSeconds, - autoStartCountdownSeconds = userPreferences.autoStartCountdownSeconds, - selectedColorSchemeIndex = userPreferences.colorScheme, - onWeightUnitChange = { viewModel.setWeightUnit(it) }, - onEnableVideoPlaybackChange = { viewModel.setEnableVideoPlayback(it) }, + onEnableVideoPlaybackChange = viewModel::setEnableVideoPlayback, onThemeModeChange = onThemeModeChange, onDynamicColorEnabledChange = onDynamicColorEnabledChange, - onAudioRepCountChange = { viewModel.setAudioRepCountEnabled(it) }, - onSummaryCountdownChange = { viewModel.setSummaryCountdownSeconds(it) }, - onAutoStartCountdownChange = { viewModel.setAutoStartCountdownSeconds(it) }, - onColorSchemeChange = { viewModel.setColorScheme(it) }, - onDeleteAllWorkouts = { viewModel.deleteAllWorkouts() }, - onNavigateToConnectionLogs = { navController.navigate(NavigationRoutes.ConnectionLogs.route) }, - onNavigateToDiagnostics = { navController.navigate(NavigationRoutes.Diagnostics.route) }, - onNavigateToBadges = { navController.navigate(NavigationRoutes.Badges.route) }, - onNavigateToLinkAccount = { navController.navigate(NavigationRoutes.LinkAccount.route) }, - onNavigateToIntegrations = { navController.navigate(NavigationRoutes.Integrations.route) }, - onNavigateToEquipmentRack = { navController.navigate(NavigationRoutes.EquipmentRack.route) }, + onDeleteAllWorkouts = viewModel::deleteAllWorkouts, + onNavigateToConnectionLogs = { + navController.navigate(NavigationRoutes.ConnectionLogs.route) + }, + onNavigateToDiagnostics = { + navController.navigate(NavigationRoutes.Diagnostics.route) + }, + onNavigateToLinkAccount = { + navController.navigate(NavigationRoutes.LinkAccount.route) + }, + onNavigateToIntegrations = { + navController.navigate(NavigationRoutes.Integrations.route) + }, connectionError = connectionError, - onClearConnectionError = { viewModel.clearConnectionError() }, - onCancelAutoConnecting = { viewModel.cancelAutoConnecting() }, - onSetTitle = { viewModel.updateTopBarTitle(it) }, - // Disco mode Easter egg - discoModeUnlocked = userPreferences.discoModeUnlocked, - discoModeActive = discoModeActive, - isConnected = connectionState is com.devil.phoenixproject.domain.model.ConnectionState.Connected, - onDiscoModeUnlocked = { viewModel.unlockDiscoMode() }, - onDiscoModeToggle = { viewModel.toggleDiscoMode(it) }, - onPlayDiscoSound = { viewModel.emitDiscoSound() }, - onTestSounds = { viewModel.testSounds() }, - // Gamification toggle - gamificationEnabled = userPreferences.gamificationEnabled, - onGamificationEnabledChange = { viewModel.setGamificationEnabled(it) }, - // Issue #333: BLE small-MTU compatibility path - bleCompatibilityMode = userPreferences.bleCompatibilityMode, - onBleCompatibilityModeChange = { viewModel.setBleCompatibilityMode(it) }, - // Auto-backup (Phase 36) - autoBackupEnabled = userPreferences.autoBackupEnabled, - onAutoBackupEnabledChange = { viewModel.setAutoBackupEnabled(it) }, + onClearConnectionError = viewModel::clearConnectionError, + onSetTitle = viewModel::updateTopBarTitle, + onTestSounds = viewModel::testSounds, + bleCompatibilityMode = globalSettings.bleCompatibilityMode, + onBleCompatibilityModeChange = viewModel::setBleCompatibilityMode, + autoBackupEnabled = globalSettings.autoBackupEnabled, + onAutoBackupEnabledChange = viewModel::setAutoBackupEnabled, backupStats = backupStats, - onOpenBackupFolder = { viewModel.openBackupFolder() }, - // Custom backup destination (Phase 42) - backupDestination = userPreferences.backupDestination, - onBackupDestinationChange = { viewModel.setBackupDestination(it) }, - // Language preference - selectedLanguage = userPreferences.language, - onLanguageChange = { viewModel.setLanguage(it) }, - // Issue #141: Voice emergency stop - voiceStopEnabled = userPreferences.voiceStopEnabled, - onVoiceStopEnabledChange = { viewModel.setVoiceStopEnabled(it) }, - safeWord = userPreferences.safeWord, - onSafeWordChange = { viewModel.setSafeWord(it) }, - safeWordCalibrated = userPreferences.safeWordCalibrated, - onSafeWordCalibratedChange = { viewModel.setSafeWordCalibrated(it) }, - // Issue #266: Configurable weight increment - weightIncrement = userPreferences.weightIncrement, - onWeightIncrementChange = { viewModel.setWeightIncrement(it) }, - // Issue #229: Body weight for bodyweight exercise volume - bodyWeightKg = userPreferences.bodyWeightKg, - onBodyWeightKgChange = { viewModel.setBodyWeightKg(it) }, - // Issue #313: VBT power loss threshold - velocityLossThresholdPercent = userPreferences.velocityLossThresholdPercent, - onVelocityLossThresholdChange = { viewModel.setVelocityLossThreshold(it) }, - autoEndOnVelocityLoss = userPreferences.autoEndOnVelocityLoss, - onAutoEndOnVelocityLossChange = { viewModel.setAutoEndOnVelocityLoss(it) }, - // Issue #517: system-wide default scaling basis - defaultScalingBasis = userPreferences.defaultScalingBasis, - onDefaultScalingBasisChange = { viewModel.setDefaultScalingBasis(it) }, - // Issue #595: routine-builder defaults for newly added cable exercises - defaultRoutineExerciseUsePercentOfPR = userPreferences.defaultRoutineExerciseUsePercentOfPR, - defaultRoutineExerciseWeightPercentOfPR = userPreferences.defaultRoutineExerciseWeightPercentOfPR, - onDefaultRoutineExerciseUsePercentOfPRChange = { viewModel.setDefaultRoutineExerciseUsePercentOfPR(it) }, - onDefaultRoutineExerciseWeightPercentOfPRChange = { viewModel.setDefaultRoutineExerciseWeightPercentOfPR(it) }, - // Issue #611: Verbal encouragement + opt-in vulgar mode + Dominatrix mode + 18+ gate - verbalEncouragementEnabled = userPreferences.verbalEncouragementEnabled, - onVerbalEncouragementEnabledChange = { viewModel.setVerbalEncouragementEnabled(it) }, - vulgarModeEnabled = userPreferences.vulgarModeEnabled, - onVulgarModeEnabledChange = { viewModel.setVulgarModeEnabled(it) }, - vulgarTier = userPreferences.vulgarTier, - onVulgarTierChange = { viewModel.setVulgarTier(it) }, - dominatrixModeUnlocked = userPreferences.dominatrixModeUnlocked, - onDominatrixModeUnlockedChange = { viewModel.setDominatrixModeUnlocked(it) }, - dominatrixModeActive = userPreferences.dominatrixModeActive, - onDominatrixModeActiveChange = { viewModel.setDominatrixModeActive(it) }, - adultsOnlyConfirmed = userPreferences.adultsOnlyConfirmed, - onAdultsOnlyConfirmedChange = { viewModel.setAdultsOnlyConfirmed(it) }, - // Issue #611: one-shot 18+ modal gate. Read reactively from - // UserPreferences so NavGraph recomposes when the flag changes. - adultsOnlyPrompted = userPreferences.adultsOnlyPrompted, - onAdultsOnlyPromptedChange = { viewModel.setAdultsOnlyPrompted(it) }, - onPlayDominatrixUnlockSound = { viewModel.emitDominatrixUnlockSound() }, + onOpenBackupFolder = viewModel::openBackupFolder, + backupDestination = globalSettings.backupDestination, + onBackupDestinationChange = viewModel::setBackupDestination, + selectedLanguage = globalSettings.language, + onLanguageChange = viewModel::setLanguage, ) } @@ -684,6 +734,7 @@ fun NavGraph( // Strength Assessment Picker - no exercise pre-selected composable( route = NavigationRoutes.StrengthAssessmentPicker.route, + arguments = listOf(navArgument("profileId") { type = NavType.StringType }), enterTransition = { slideIntoContainer( towards = AnimatedContentTransitionScope.SlideDirection.Left, @@ -708,10 +759,12 @@ fun NavGraph( animationSpec = tween(300), ) }, - ) { - val assessmentViewModel: AssessmentViewModel = koinInject() - AssessmentWizardScreen( - viewModel = assessmentViewModel, + ) { backStackEntry -> + val profileId = + backStackEntry.arguments?.read { getStringOrNull("profileId") }.orEmpty() + AssessmentDestination( + routeProfileId = profileId, + exerciseId = null, themeMode = themeMode, onNavigateBack = { navController.popBackStack() }, metricsFlow = viewModel.currentMetric, @@ -721,7 +774,10 @@ fun NavGraph( // Strength Assessment with pre-selected exercise composable( route = NavigationRoutes.StrengthAssessment.route, - arguments = listOf(navArgument("exerciseId") { type = NavType.StringType }), + arguments = listOf( + navArgument("profileId") { type = NavType.StringType }, + navArgument("exerciseId") { type = NavType.StringType }, + ), enterTransition = { slideIntoContainer( towards = AnimatedContentTransitionScope.SlideDirection.Left, @@ -747,10 +803,12 @@ fun NavGraph( ) }, ) { backStackEntry -> - val exerciseId = backStackEntry.arguments?.read { getStringOrNull("exerciseId") } ?: "" - val assessmentViewModel: AssessmentViewModel = koinInject() - AssessmentWizardScreen( - viewModel = assessmentViewModel, + val profileId = + backStackEntry.arguments?.read { getStringOrNull("profileId") }.orEmpty() + val exerciseId = + backStackEntry.arguments?.read { getStringOrNull("exerciseId") }.orEmpty() + AssessmentDestination( + routeProfileId = profileId, exerciseId = exerciseId, themeMode = themeMode, onNavigateBack = { navController.popBackStack() }, diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/navigation/NavigationRoutes.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/navigation/NavigationRoutes.kt index 5bf5abfeb..038b9f40a 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/navigation/NavigationRoutes.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/navigation/NavigationRoutes.kt @@ -42,15 +42,26 @@ sealed class NavigationRoutes(val route: String) { // Smart Insights - training suggestions and readiness object SmartInsights : NavigationRoutes("smart_insights") + object Profile : NavigationRoutes("profile") // Cloud Sync routes object LinkAccount : NavigationRoutes("link_account") // Strength Assessment - VBT-based 1RM estimation with BLE velocity capture - object StrengthAssessment : NavigationRoutes("strength_assessment/{exerciseId}") { - fun createRoute(exerciseId: String) = "strength_assessment/${exerciseId.encodeRouteSegment()}" + object StrengthAssessment : NavigationRoutes("strength_assessment/{profileId}/{exerciseId}") { + fun createRoute(profileId: String, exerciseId: String): String { + require(profileId.isNotBlank()) { "Assessment profileId must not be blank" } + require(exerciseId.isNotBlank()) { "Assessment exerciseId must not be blank" } + return "strength_assessment/${profileId.encodeRouteSegment()}/${exerciseId.encodeRouteSegment()}" + } + } + + object StrengthAssessmentPicker : NavigationRoutes("strength_assessment_picker/{profileId}") { + fun createRoute(profileId: String): String { + require(profileId.isNotBlank()) { "Assessment profileId must not be blank" } + return "strength_assessment_picker/${profileId.encodeRouteSegment()}" + } } - object StrengthAssessmentPicker : NavigationRoutes("strength_assessment_picker") // Integration routes object Integrations : NavigationRoutes("integrations") @@ -103,11 +114,12 @@ fun NavController.safePopOrNavigate(dest: String) { } /** - * Bottom navigation items. - * Only 3 items are shown in the bottom navigation bar. + * Canonical root navigation order. */ -enum class BottomNavItem(val route: String, val label: String) { - WORKOUT("home", "Workout"), - ANALYTICS("analytics", "Analytics"), - SETTINGS("settings", "Settings"), +enum class BottomNavItem(val route: String) { + ANALYTICS(NavigationRoutes.Analytics.route), + INSIGHTS(NavigationRoutes.SmartInsights.route), + HOME(NavigationRoutes.Home.route), + PROFILE(NavigationRoutes.Profile.route), + SETTINGS(NavigationRoutes.Settings.route), } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ActiveWorkoutScreen.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ActiveWorkoutScreen.kt index 6cd8ae8f0..9c5d3f049 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ActiveWorkoutScreen.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ActiveWorkoutScreen.kt @@ -359,6 +359,7 @@ fun ActiveWorkoutScreen(navController: NavController, viewModel: MainViewModel, motionStartHoldProgress, isRestPaused, currentWarmupSetIndex, totalWarmupSets, justLiftRestCountdown, isExerciseTimerPaused, + userPreferences.vbtEnabled, userPreferences.velocityLossThresholdPercent, userPreferences.effectiveWeightIncrementKg, currentRackLoadAdjustment, @@ -398,6 +399,7 @@ fun ActiveWorkoutScreen(navController: NavController, viewModel: MainViewModel, totalWarmupSets = totalWarmupSets, justLiftRestCountdown = justLiftRestCountdown, isExerciseTimerPaused = isExerciseTimerPaused, + vbtEnabled = userPreferences.vbtEnabled, velocityLossThresholdPercent = userPreferences.velocityLossThresholdPercent, weightStepKg = userPreferences.effectiveWeightIncrementKg, rackLoadAdjustment = currentRackLoadAdjustment, diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/AnalyticsScreen.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/AnalyticsScreen.kt index 55bd1ea39..d03b47952 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/AnalyticsScreen.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/AnalyticsScreen.kt @@ -53,6 +53,7 @@ fun ProgressTab( exerciseRepository: ExerciseRepository, weightUnit: WeightUnit, formatWeight: (Float, WeightUnit) -> String, + assessmentEnabled: Boolean, onNavigateToStrengthAssessment: () -> Unit, modifier: Modifier = Modifier, ) { @@ -98,6 +99,7 @@ fun ProgressTab( item { ExpressiveCard( onClick = onNavigateToStrengthAssessment, + enabled = assessmentEnabled, modifier = Modifier.fillMaxWidth(), ) { Row( @@ -264,7 +266,8 @@ fun ProgressTab( fun AnalyticsScreen( viewModel: MainViewModel, themeMode: com.devil.phoenixproject.ui.theme.ThemeMode, - onNavigateToStrengthAssessment: () -> Unit, + assessmentProfileId: String?, + onNavigateToStrengthAssessment: (String) -> Unit, ) { val workoutHistory by viewModel.workoutHistory.collectAsState() val groupedWorkoutHistory by viewModel.groupedWorkoutHistory.collectAsState() @@ -478,7 +481,10 @@ fun AnalyticsScreen( exerciseRepository = viewModel.exerciseRepository, weightUnit = weightUnit, formatWeight = viewModel::formatWeight, - onNavigateToStrengthAssessment = onNavigateToStrengthAssessment, + assessmentEnabled = assessmentProfileId != null, + onNavigateToStrengthAssessment = { + assessmentProfileId?.let(onNavigateToStrengthAssessment) + }, modifier = Modifier.fillMaxSize(), ) diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/AssessmentWizardScreen.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/AssessmentWizardScreen.kt index 35ad4db7c..34c9e7521 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/AssessmentWizardScreen.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/AssessmentWizardScreen.kt @@ -32,6 +32,7 @@ import com.devil.phoenixproject.presentation.components.LoadingIndicatorSize import com.devil.phoenixproject.presentation.components.VideoPlayer import com.devil.phoenixproject.presentation.util.LocalPlatformAccessibilitySettings import com.devil.phoenixproject.presentation.viewmodel.AssessmentStep +import com.devil.phoenixproject.presentation.viewmodel.AssessmentUiEvent import com.devil.phoenixproject.presentation.viewmodel.AssessmentViewModel import com.devil.phoenixproject.ui.theme.AccessibilityTheme import com.devil.phoenixproject.ui.theme.ExpressiveMotion @@ -65,13 +66,25 @@ import vitruvianprojectphoenix.shared.generated.resources.Res @Composable fun AssessmentWizardScreen( viewModel: AssessmentViewModel, + profileId: String, exerciseId: String? = null, themeMode: ThemeMode, onNavigateBack: () -> Unit, metricsFlow: StateFlow? = null, ) { + require(profileId.isNotBlank()) { "Assessment profileId must not be blank" } val currentStep by viewModel.currentStep.collectAsState() val exercises by viewModel.exercises.collectAsState() + val snackbarHostState = remember { SnackbarHostState() } + val saveFailureMessage = stringResource(Res.string.assessment_save_failed) + LaunchedEffect(viewModel, saveFailureMessage) { + viewModel.events.collect { event -> + when (event) { + AssessmentUiEvent.SaveFailed -> + snackbarHostState.showSnackbar(saveFailureMessage) + } + } + } // Auto-select exercise if ID is provided LaunchedEffect(exerciseId, exercises) { @@ -124,7 +137,12 @@ fun AssessmentWizardScreen( is AssessmentStep.Results -> ResultsContent( step = step, - onAccept = viewModel::acceptResult, + onAccept = { overrideKg -> + viewModel.acceptResult( + profileId = profileId, + overrideKg = overrideKg, + ) + }, onDiscard = viewModel::reset, ) @@ -137,6 +155,10 @@ fun AssessmentWizardScreen( } } } + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.align(Alignment.BottomCenter), + ) } } @@ -282,13 +304,6 @@ private fun ExerciseSelectionContent( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - if (exercise.oneRepMaxKg != null) { - Text( - text = "${exercise.oneRepMaxKg} kg", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.tertiary, - ) - } } } } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/EnhancedMainScreen.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/EnhancedMainScreen.kt index 5e54ddf5f..95f81ad9e 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/EnhancedMainScreen.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/EnhancedMainScreen.kt @@ -6,8 +6,10 @@ import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -23,6 +25,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack @@ -31,6 +34,7 @@ import androidx.compose.material.icons.filled.BarChart import androidx.compose.material.icons.filled.CloudDone import androidx.compose.material.icons.filled.CloudOff import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Sync import androidx.compose.material3.ExperimentalMaterial3Api @@ -50,7 +54,6 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -59,10 +62,14 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.onLongClick import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.selected import androidx.compose.ui.text.font.FontWeight @@ -70,16 +77,19 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController +import com.devil.phoenixproject.data.repository.ActiveProfileContext import com.devil.phoenixproject.data.repository.ExerciseRepository import com.devil.phoenixproject.data.repository.UserProfileRepository import com.devil.phoenixproject.data.sync.SyncManager import com.devil.phoenixproject.data.sync.SyncState import com.devil.phoenixproject.domain.model.ConnectionState import com.devil.phoenixproject.domain.model.WorkoutState -import com.devil.phoenixproject.presentation.components.AddProfileDialog import com.devil.phoenixproject.presentation.components.ConnectionLostDialog import com.devil.phoenixproject.presentation.components.HapticFeedbackEffect -import com.devil.phoenixproject.presentation.components.ProfileSidePanel +import com.devil.phoenixproject.presentation.components.ProfileAddDialog +import com.devil.phoenixproject.presentation.components.ProfileRecoveryDialog +import com.devil.phoenixproject.presentation.components.ProfileSwitcherSheet +import com.devil.phoenixproject.presentation.navigation.BottomNavItem import com.devil.phoenixproject.presentation.navigation.NavGraph import com.devil.phoenixproject.presentation.navigation.NavigationRoutes import com.devil.phoenixproject.presentation.theme.phoenixBottomNavigationContainerColor @@ -91,23 +101,34 @@ import com.devil.phoenixproject.presentation.util.calculateWindowSizeClass import com.devil.phoenixproject.presentation.util.isCompactAccessibilityLayout import com.devil.phoenixproject.presentation.util.rememberPlatformAccessibilitySettings import com.devil.phoenixproject.presentation.viewmodel.MainViewModel +import com.devil.phoenixproject.presentation.viewmodel.ProfileOverlayError +import com.devil.phoenixproject.presentation.viewmodel.ProfileSwitcherViewModel +import com.devil.phoenixproject.presentation.viewmodel.RootProfileOperationKind import com.devil.phoenixproject.ui.theme.AccessibilityTheme import com.devil.phoenixproject.ui.theme.ThemeMode import com.devil.phoenixproject.util.setKeepScreenOn import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel import vitruvianprojectphoenix.shared.generated.resources.Res import vitruvianprojectphoenix.shared.generated.resources.cd_analytics import vitruvianprojectphoenix.shared.generated.resources.cd_back +import vitruvianprojectphoenix.shared.generated.resources.cd_home +import vitruvianprojectphoenix.shared.generated.resources.cd_open_profile_switcher +import vitruvianprojectphoenix.shared.generated.resources.cd_profile import vitruvianprojectphoenix.shared.generated.resources.cd_settings -import vitruvianprojectphoenix.shared.generated.resources.cd_workouts -import vitruvianprojectphoenix.shared.generated.resources.insights_title +import vitruvianprojectphoenix.shared.generated.resources.nav_insights +import vitruvianprojectphoenix.shared.generated.resources.nav_profile +import vitruvianprojectphoenix.shared.generated.resources.profile_create_failed +import vitruvianprojectphoenix.shared.generated.resources.profile_recovery_retry_failed +import vitruvianprojectphoenix.shared.generated.resources.profile_switch_failed /** * Enhanced main screen with dynamic top bar and bottom navigation. * Provides consistent scaffolding across all screens with: * - Dynamic TopAppBar (title, back button, actions, connection status, theme toggle) - * - Compact bottom navigation (Analytics, Workouts, Insights, Settings) + * - Compact bottom navigation (Analytics, Insights, Home, Profile, Settings) + * - Home opens the existing Workouts destination * - Conditional visibility based on current route */ @OptIn(ExperimentalMaterial3Api::class) @@ -120,6 +141,7 @@ fun EnhancedMainScreen( dynamicColorAvailable: Boolean, dynamicColorEnabled: Boolean, onDynamicColorEnabledChange: (Boolean) -> Unit, + profileSwitcherViewModel: ProfileSwitcherViewModel = koinViewModel(), navController: NavHostController = rememberNavController(), ) { val workoutState by viewModel.workoutState.collectAsState() @@ -142,12 +164,23 @@ fun EnhancedMainScreen( val currentExerciseIndex by viewModel.currentExerciseIndex.collectAsState() val selectedExerciseName = loadedRoutine?.exercises?.getOrNull(currentExerciseIndex)?.exercise?.name ?: "" - // Profile management - val scope = rememberCoroutineScope() + // Root-owned profile switching and recovery val profileRepository: UserProfileRepository = koinInject() val profiles by profileRepository.allProfiles.collectAsState() - val activeProfile by profileRepository.activeProfile.collectAsState() - var showAddProfileDialog by remember { mutableStateOf(false) } + val activeProfileContext by profileRepository.activeProfileContext.collectAsState() + val switcherState by profileSwitcherViewModel.uiState.collectAsState() + val readyProfileId = + (activeProfileContext as? ActiveProfileContext.Ready)?.profile?.id + val repositorySwitching = activeProfileContext is ActiveProfileContext.Switching + val repositorySwitchTarget = + (activeProfileContext as? ActiveProfileContext.Switching)?.targetProfileId + val localSwitchTarget = switcherState.operation + ?.takeIf { it.kind == RootProfileOperationKind.SWITCH } + ?.targetProfileId + val localOperationInFlight = switcherState.operation != null + val switchingInFlight = repositorySwitching || localOperationInFlight + val switchingTargetProfileId = repositorySwitchTarget ?: localSwitchTarget + val hapticFeedback = LocalHapticFeedback.current // Ensure default profile exists LaunchedEffect(Unit) { @@ -208,8 +241,15 @@ fun EnhancedMainScreen( } } - // Helper function to determine if current route is a "Workouts" route - val isWorkoutsRoute = remember(currentRoute) { + val profileTitle = stringResource(Res.string.nav_profile) + val profileContentDescription = stringResource(Res.string.cd_profile) + val openProfileSwitcherDescription = stringResource(Res.string.cd_open_profile_switcher) + val switchFailedMessage = stringResource(Res.string.profile_switch_failed) + val createFailedMessage = stringResource(Res.string.profile_create_failed) + val recoveryRetryFailedMessage = stringResource(Res.string.profile_recovery_retry_failed) + + // Helper function to determine if the Home tab owns the current workout-flow route + val isHomeRoute = remember(currentRoute) { currentRoute == NavigationRoutes.Home.route || currentRoute == NavigationRoutes.JustLift.route || isSingleExerciseRoute(currentRoute) || @@ -232,6 +272,7 @@ fun EnhancedMainScreen( currentRoute == NavigationRoutes.TrainingCycles.route || currentRoute == NavigationRoutes.Analytics.route || currentRoute == NavigationRoutes.SmartInsights.route || + currentRoute == NavigationRoutes.Profile.route || currentRoute == NavigationRoutes.Settings.route } @@ -255,6 +296,7 @@ fun EnhancedMainScreen( } else { getScreenTitle( route = currentRoute, + profileTitle = profileTitle, routineName = currentRoutineName, exerciseName = selectedExerciseName, cycleName = editingCycleName, @@ -385,11 +427,13 @@ fun EnhancedMainScreen( if (shouldShowBottomBar) { PhoenixBottomNavigationBar( currentRoute = currentRoute, - isWorkoutsRoute = isWorkoutsRoute, + isHomeRoute = isHomeRoute, isCompactHeight = useCompactTopBar, analyticsContentDescription = stringResource(Res.string.cd_analytics), - workoutsContentDescription = stringResource(Res.string.cd_workouts), - insightsContentDescription = stringResource(Res.string.insights_title), + homeContentDescription = stringResource(Res.string.cd_home), + insightsContentDescription = stringResource(Res.string.nav_insights), + profileContentDescription = profileContentDescription, + openProfileSwitcherDescription = openProfileSwitcherDescription, settingsContentDescription = stringResource(Res.string.cd_settings), onAnalyticsClick = { if (currentRoute != NavigationRoutes.Analytics.route) { @@ -400,7 +444,7 @@ fun EnhancedMainScreen( } } }, - onWorkoutsClick = { + onHomeClick = { if (currentRoute != NavigationRoutes.Home.route) { navController.navigate(NavigationRoutes.Home.route) { popUpTo(NavigationRoutes.Home.route) { inclusive = true } @@ -417,6 +461,19 @@ fun EnhancedMainScreen( } } }, + onProfileClick = { + if (currentRoute != NavigationRoutes.Profile.route) { + navController.navigate(NavigationRoutes.Profile.route) { + popUpTo(NavigationRoutes.Home.route) { saveState = true } + launchSingleTop = true + restoreState = true + } + } + }, + onProfileLongClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + profileSwitcherViewModel.openSwitcher() + }, onSettingsClick = { if (currentRoute != NavigationRoutes.Settings.route) { navController.navigate(NavigationRoutes.Settings.route) { @@ -444,19 +501,10 @@ fun EnhancedMainScreen( dynamicColorAvailable = dynamicColorAvailable, dynamicColorEnabled = dynamicColorEnabled, onDynamicColorEnabledChange = onDynamicColorEnabledChange, + onOpenProfileSwitcher = profileSwitcherViewModel::openSwitcher, + onProfileRecoveryRequired = profileSwitcherViewModel::requireRecovery, modifier = Modifier.padding(padding), ) - - // Profile side panel (only on Home screen) - if (currentRoute == NavigationRoutes.Home.route) { - ProfileSidePanel( - profiles = profiles, - activeProfile = activeProfile, - profileRepository = profileRepository, - scope = scope, - onAddProfile = { showAddProfileDialog = true }, - ) - } } } @@ -472,13 +520,42 @@ fun EnhancedMainScreen( ) } - // Add Profile Dialog - if (showAddProfileDialog) { - AddProfileDialog( + if (switcherState.showSwitcher) { + ProfileSwitcherSheet( profiles = profiles, - profileRepository = profileRepository, - scope = scope, - onDismiss = { showAddProfileDialog = false }, + activeProfileId = readyProfileId, + switchingInFlight = switchingInFlight, + switchingTargetProfileId = switchingTargetProfileId, + errorMessage = switchFailedMessage.takeIf { + switcherState.error == ProfileOverlayError.SWITCH_FAILED + }, + onSelectProfile = { profile -> + profileSwitcherViewModel.switchProfile(profile.id) + }, + onAddProfile = profileSwitcherViewModel::openAddDialog, + onDismiss = profileSwitcherViewModel::dismissSwitcher, + ) + } + + if (switcherState.showAddDialog) { + ProfileAddDialog( + existingProfileCount = profiles.size, + isSubmitting = switchingInFlight, + errorMessage = createFailedMessage.takeIf { + switcherState.error == ProfileOverlayError.CREATE_FAILED + }, + onConfirm = profileSwitcherViewModel::createAndActivateProfile, + onDismiss = profileSwitcherViewModel::dismissAddDialog, + ) + } + + if (switcherState.recoveryRequired) { + ProfileRecoveryDialog( + isRetrying = switcherState.operation?.kind == RootProfileOperationKind.RECOVERY, + errorMessage = recoveryRetryFailedMessage.takeIf { + switcherState.error == ProfileOverlayError.RECOVERY_RETRY_FAILED + }, + onRetry = profileSwitcherViewModel::retryRecovery, ) } } // CompositionLocalProvider @@ -488,15 +565,19 @@ fun EnhancedMainScreen( @Composable private fun PhoenixBottomNavigationBar( currentRoute: String, - isWorkoutsRoute: Boolean, + isHomeRoute: Boolean, isCompactHeight: Boolean, analyticsContentDescription: String, - workoutsContentDescription: String, + homeContentDescription: String, insightsContentDescription: String, + profileContentDescription: String, + openProfileSwitcherDescription: String, settingsContentDescription: String, onAnalyticsClick: () -> Unit, - onWorkoutsClick: () -> Unit, + onHomeClick: () -> Unit, onInsightsClick: () -> Unit, + onProfileClick: () -> Unit, + onProfileLongClick: () -> Unit, onSettingsClick: () -> Unit, ) { val containerColor = phoenixBottomNavigationContainerColor(MaterialTheme.colorScheme) @@ -512,49 +593,83 @@ private fun PhoenixBottomNavigationBar( modifier = Modifier .fillMaxWidth() .height(barHeight) - .padding(horizontal = 24.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), + .padding(horizontal = 8.dp) + .selectableGroup(), + horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically, ) { - PhoenixBottomNavigationItem( - icon = Icons.Default.BarChart, - contentDescription = analyticsContentDescription, - selected = currentRoute == NavigationRoutes.Analytics.route, - onClick = onAnalyticsClick, - modifier = Modifier.weight(1f), - ) - PhoenixBottomNavigationItem( - icon = Icons.Default.Home, - contentDescription = workoutsContentDescription, - selected = isWorkoutsRoute, - onClick = onWorkoutsClick, - modifier = Modifier.weight(1f), - ) - PhoenixBottomNavigationItem( - icon = Icons.Default.AutoAwesome, - contentDescription = insightsContentDescription, - selected = currentRoute == NavigationRoutes.SmartInsights.route, - onClick = onInsightsClick, - modifier = Modifier.weight(1f), - ) - PhoenixBottomNavigationItem( - icon = Icons.Default.Settings, - contentDescription = settingsContentDescription, - selected = currentRoute == NavigationRoutes.Settings.route, - onClick = onSettingsClick, - modifier = Modifier.weight(1f), - ) + BottomNavItem.entries.forEach { item -> + val icon = when (item) { + BottomNavItem.ANALYTICS -> Icons.Default.BarChart + BottomNavItem.INSIGHTS -> Icons.Default.AutoAwesome + BottomNavItem.HOME -> Icons.Default.Home + BottomNavItem.PROFILE -> Icons.Default.Person + BottomNavItem.SETTINGS -> Icons.Default.Settings + } + val contentDescription = when (item) { + BottomNavItem.ANALYTICS -> analyticsContentDescription + BottomNavItem.INSIGHTS -> insightsContentDescription + BottomNavItem.HOME -> homeContentDescription + BottomNavItem.PROFILE -> profileContentDescription + BottomNavItem.SETTINGS -> settingsContentDescription + } + val selected = when (item) { + BottomNavItem.ANALYTICS -> currentRoute == NavigationRoutes.Analytics.route + BottomNavItem.INSIGHTS -> currentRoute == NavigationRoutes.SmartInsights.route + BottomNavItem.HOME -> isHomeRoute + BottomNavItem.PROFILE -> currentRoute == NavigationRoutes.Profile.route + BottomNavItem.SETTINGS -> currentRoute == NavigationRoutes.Settings.route + } + val testTag = when (item) { + BottomNavItem.ANALYTICS -> com.devil.phoenixproject.presentation.util.TestTags.NAV_ANALYTICS + BottomNavItem.INSIGHTS -> com.devil.phoenixproject.presentation.util.TestTags.NAV_INSIGHTS + BottomNavItem.HOME -> com.devil.phoenixproject.presentation.util.TestTags.NAV_HOME + BottomNavItem.PROFILE -> com.devil.phoenixproject.presentation.util.TestTags.NAV_PROFILE + BottomNavItem.SETTINGS -> com.devil.phoenixproject.presentation.util.TestTags.NAV_SETTINGS + } + val onClick = when (item) { + BottomNavItem.ANALYTICS -> onAnalyticsClick + BottomNavItem.INSIGHTS -> onInsightsClick + BottomNavItem.HOME -> onHomeClick + BottomNavItem.PROFILE -> onProfileClick + BottomNavItem.SETTINGS -> onSettingsClick + } + val itemLongClick = when (item) { + BottomNavItem.PROFILE -> onProfileLongClick + BottomNavItem.ANALYTICS, + BottomNavItem.INSIGHTS, + BottomNavItem.HOME, + BottomNavItem.SETTINGS, + -> null + } + PhoenixBottomNavigationItem( + icon = icon, + contentDescription = contentDescription, + selected = selected, + testTag = testTag, + onClick = onClick, + modifier = Modifier.weight(1f), + onLongClick = itemLongClick, + longClickLabel = openProfileSwitcherDescription.takeIf { + item == BottomNavItem.PROFILE + }, + ) + } } } } +@OptIn(ExperimentalFoundationApi::class) @Composable private fun PhoenixBottomNavigationItem( icon: ImageVector, contentDescription: String, selected: Boolean, + testTag: String, onClick: () -> Unit, modifier: Modifier = Modifier, + onLongClick: (() -> Unit)? = null, + longClickLabel: String? = null, ) { val selectedContainerColor = if (selected) { MaterialTheme.colorScheme.primary.copy(alpha = 0.22f) @@ -568,34 +683,40 @@ private fun PhoenixBottomNavigationItem( } Box( - modifier = modifier, - contentAlignment = Alignment.Center, - ) { - Box( - modifier = Modifier - .height(48.dp) - .widthIn(min = 64.dp, max = 112.dp) - .clip(MaterialTheme.shapes.large) - .background(selectedContainerColor) - .clickable(role = Role.Tab, onClick = onClick) - .clearAndSetSemantics { - this.contentDescription = contentDescription - role = Role.Tab - this.selected = selected - this.onClick(label = contentDescription) { - onClick() + modifier = modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .clip(MaterialTheme.shapes.large) + .background(selectedContainerColor) + .combinedClickable( + role = Role.Tab, + onClick = onClick, + onLongClick = onLongClick, + ) + .clearAndSetSemantics { + this.contentDescription = contentDescription + role = Role.Tab + this.selected = selected + this.onClick(label = contentDescription) { + onClick() + true + } + if (onLongClick != null && longClickLabel != null) { + this.onLongClick(label = longClickLabel) { + onLongClick() true } - }, - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(26.dp), - tint = iconColor, - ) - } + } + } + .testTag(testTag), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(26.dp), + tint = iconColor, + ) } } @@ -868,7 +989,13 @@ private fun isSingleExerciseRoute(route: String): Boolean = route == NavigationR internal fun shouldKeepScreenOnForRoute(route: String, isInWorkoutSession: Boolean): Boolean = isInWorkoutSession || route == NavigationRoutes.JustLift.route -private fun getScreenTitle(route: String, routineName: String = "", exerciseName: String = "", cycleName: String = ""): String = when { +private fun getScreenTitle( + route: String, + profileTitle: String, + routineName: String = "", + exerciseName: String = "", + cycleName: String = "", +): String = when { // Main tabs (static titles) route == NavigationRoutes.Home.route -> "Choose Your Workout" @@ -880,6 +1007,8 @@ private fun getScreenTitle(route: String, routineName: String = "", exerciseName route == NavigationRoutes.SmartInsights.route -> "Smart Insights" + route == NavigationRoutes.Profile.route -> profileTitle + route == NavigationRoutes.Settings.route -> "Settings" route == NavigationRoutes.EquipmentRack.route -> "Equipment Rack" @@ -925,6 +1054,7 @@ private fun getCompactScreenTitle(route: String, title: String): String = when { route == NavigationRoutes.DailyRoutines.route -> "Routines" route == NavigationRoutes.TrainingCycles.route -> "Cycles" route == NavigationRoutes.SmartInsights.route -> "Insights" + route == NavigationRoutes.Profile.route -> title route == NavigationRoutes.EquipmentRack.route -> "Rack" isSingleExerciseRoute(route) -> "Exercise" route.startsWith("cycle_editor") -> "Cycle" diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailScreen.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailScreen.kt index 769bc2d24..36593e8f4 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailScreen.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailScreen.kt @@ -26,14 +26,16 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.navigation.NavController import com.devil.phoenixproject.data.repository.ExerciseRepository -import com.devil.phoenixproject.data.repository.VelocityOneRepMaxEntity import com.devil.phoenixproject.domain.model.ConnectionState import com.devil.phoenixproject.domain.model.WeightUnit import com.devil.phoenixproject.domain.model.WorkoutSession import com.devil.phoenixproject.domain.model.effectiveTotalVolumeKg +import com.devil.phoenixproject.domain.usecase.CurrentOneRepMax +import com.devil.phoenixproject.domain.usecase.CurrentOneRepMaxSource +import com.devil.phoenixproject.domain.usecase.ResolveCurrentOneRepMaxUseCase +import com.devil.phoenixproject.domain.usecase.estimatedOneRepMaxPerCableOrNull import com.devil.phoenixproject.presentation.components.charts.ProgressionLineChart import com.devil.phoenixproject.presentation.components.charts.VolumeTrendChart -import com.devil.phoenixproject.presentation.navigation.NavigationRoutes import com.devil.phoenixproject.presentation.util.WeightDisplayFormatter import com.devil.phoenixproject.presentation.viewmodel.MainViewModel import com.devil.phoenixproject.ui.theme.AccessibilityTheme @@ -41,13 +43,63 @@ import com.devil.phoenixproject.ui.theme.Spacing import com.devil.phoenixproject.ui.theme.ThemeMode import com.devil.phoenixproject.ui.theme.screenBackgroundBrush import com.devil.phoenixproject.util.KmpUtils -import com.devil.phoenixproject.util.OneRepMaxCalculator import com.devil.phoenixproject.presentation.components.ExpressiveCard import com.devil.phoenixproject.presentation.components.ShimmerBox +import kotlin.coroutines.cancellation.CancellationException import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject import vitruvianprojectphoenix.shared.generated.resources.* import vitruvianprojectphoenix.shared.generated.resources.Res +internal data class ExerciseDetailOneRepMaxRequest( + val exerciseId: String, + val profileId: String, + val completedSessions: List, +) + +internal data class ExerciseDetailOneRepMaxLoadToken( + val generation: Long, + val request: ExerciseDetailOneRepMaxRequest, +) + +internal class ExerciseDetailOneRepMaxLoadGate { + private var generation = 0L + private var active: ExerciseDetailOneRepMaxLoadToken? = null + + fun begin(request: ExerciseDetailOneRepMaxRequest): ExerciseDetailOneRepMaxLoadToken = + ExerciseDetailOneRepMaxLoadToken(++generation, request).also { active = it } + + fun isCurrent(token: ExerciseDetailOneRepMaxLoadToken): Boolean = active == token +} + +internal sealed interface ExerciseDetailOneRepMaxState { + data object Loading : ExerciseDetailOneRepMaxState + data class Ready(val resolution: CurrentOneRepMax?) : ExerciseDetailOneRepMaxState + data object Failed : ExerciseDetailOneRepMaxState +} + +internal suspend fun loadExerciseDetailOneRepMax( + request: ExerciseDetailOneRepMaxRequest, + gate: ExerciseDetailOneRepMaxLoadGate, + resolve: suspend (exerciseId: String, profileId: String) -> CurrentOneRepMax?, + publish: (ExerciseDetailOneRepMaxState) -> Unit, +) { + val token = gate.begin(request) + publish(ExerciseDetailOneRepMaxState.Loading) + try { + val resolution = resolve(request.exerciseId, request.profileId) + if (gate.isCurrent(token)) { + publish(ExerciseDetailOneRepMaxState.Ready(resolution)) + } + } catch (cancellation: CancellationException) { + throw cancellation + } catch (_: Exception) { + if (gate.isCurrent(token)) { + publish(ExerciseDetailOneRepMaxState.Failed) + } + } +} + /** * Detail screen for a single exercise. * Shows 1RM progression, weight trend, volume chart, and workout history. @@ -55,17 +107,34 @@ import vitruvianprojectphoenix.shared.generated.resources.Res */ @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ExerciseDetailScreen(exerciseId: String, navController: NavController, viewModel: MainViewModel, themeMode: ThemeMode) { +fun ExerciseDetailScreen( + exerciseId: String, + navController: NavController, + viewModel: MainViewModel, + themeMode: ThemeMode, + assessmentProfileId: String?, + onNavigateToStrengthAssessment: (String) -> Unit, +) { val allWorkoutSessions by viewModel.allWorkoutSessions.collectAsState() val weightUnit by viewModel.weightUnit.collectAsState() val connectionState by viewModel.connectionState.collectAsState() val isConnected = connectionState is ConnectionState.Connected - // Filter sessions for this exercise - val exerciseSessions = remember(allWorkoutSessions, exerciseId) { - allWorkoutSessions - .filter { it.exerciseId == exerciseId } - .sortedByDescending { it.timestamp } + val profileId = assessmentProfileId + val exerciseSessions = remember(allWorkoutSessions, exerciseId, profileId) { + if (profileId == null) { + emptyList() + } else { + allWorkoutSessions + .asSequence() + .filter { it.profileId == profileId && it.exerciseId == exerciseId } + .filter { it.workingReps > 0 || it.totalReps > 0 } + .sortedWith( + compareByDescending { it.timestamp } + .thenByDescending { it.id }, + ) + .toList() + } } // Get exercise name — null while the repository call is in flight @@ -78,26 +147,45 @@ fun ExerciseDetailScreen(exerciseId: String, navController: NavController, viewM viewModel.updateTopBarTitle("") } - // Velocity-based 1RM estimate (issue #517): latest estimate that passed the quality gate, - // scoped to the active profile. Keyed on exerciseId + profileId so the value resets (no - // stale flash) when either changes. - val profileId by viewModel.activeProfileId.collectAsState() - var velocity1Rm by remember(exerciseId, profileId) { mutableStateOf(null) } - LaunchedEffect(exerciseId, profileId) { - velocity1Rm = viewModel.velocityOneRepMaxRepository.getLatestPassing(exerciseId, profileId) + val resolveCurrentOneRepMax: ResolveCurrentOneRepMaxUseCase = koinInject() + val loadGate = remember { ExerciseDetailOneRepMaxLoadGate() } + val request = remember(exerciseId, profileId, exerciseSessions) { + profileId?.let { + ExerciseDetailOneRepMaxRequest( + exerciseId = exerciseId, + profileId = it, + completedSessions = exerciseSessions, + ) + } + } + val stateHolder = remember(request) { + mutableStateOf( + ExerciseDetailOneRepMaxState.Loading, + ) + } + val oneRepMaxState by stateHolder + + LaunchedEffect(request, resolveCurrentOneRepMax) { + val currentRequest = request ?: return@LaunchedEffect + loadExerciseDetailOneRepMax( + request = currentRequest, + gate = loadGate, + resolve = resolveCurrentOneRepMax::invoke, + publish = { stateHolder.value = it }, + ) } - // Calculate 1RM progression using saved per-cable load. - val oneRepMaxData = remember(exerciseSessions) { + val validSessionEstimatesNewestFirst = remember(exerciseSessions) { exerciseSessions.mapNotNull { session -> - if (session.workingReps > 0) { - val oneRm = calculateOneRepMax(session.weightPerCableKg, session.workingReps) - session.timestamp to oneRm - } else { - null - } - }.reversed() // Chronological order for chart + session.estimatedOneRepMaxPerCableOrNull() + ?.let { estimate -> session.timestamp to estimate } + } + } + val oneRepMaxData = remember(validSessionEstimatesNewestFirst) { + validSessionEstimatesNewestFirst.reversed() } + val previousSessionOneRepMax = + validSessionEstimatesNewestFirst.getOrNull(1)?.second // Weight-over-time trend data using saved per-cable load. val weightTrendData = remember(exerciseSessions) { @@ -115,9 +203,6 @@ fun ExerciseDetailScreen(exerciseId: String, navController: NavController, viewM exerciseSessions.reversed() } - val currentOneRepMax = oneRepMaxData.lastOrNull()?.second - val previousOneRepMax = if (oneRepMaxData.size >= 2) oneRepMaxData[oneRepMaxData.size - 2].second else null - // View mode toggle: "charts" or "table" var viewMode by remember { mutableStateOf("charts") } @@ -156,11 +241,10 @@ fun ExerciseDetailScreen(exerciseId: String, navController: NavController, viewM // 1RM Hero Card item { OneRepMaxCard( - currentOneRepMax = currentOneRepMax, - previousOneRepMax = previousOneRepMax, + state = oneRepMaxState, + previousSessionOneRepMax = previousSessionOneRepMax, weightUnit = weightUnit, formatWeight = viewModel::formatWeight, - velocity1Rm = velocity1Rm, ) } @@ -168,11 +252,9 @@ fun ExerciseDetailScreen(exerciseId: String, navController: NavController, viewM item { OutlinedButton( onClick = { - navController.navigate( - NavigationRoutes.StrengthAssessment.createRoute(exerciseId), - ) + assessmentProfileId?.let(onNavigateToStrengthAssessment) }, - enabled = isConnected, + enabled = isConnected && assessmentProfileId != null, modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.small, ) { @@ -277,14 +359,17 @@ fun ExerciseDetailScreen(exerciseId: String, navController: NavController, viewM @Composable private fun OneRepMaxCard( - currentOneRepMax: Float?, - previousOneRepMax: Float?, + state: ExerciseDetailOneRepMaxState, + previousSessionOneRepMax: Float?, weightUnit: WeightUnit, formatWeight: (Float, WeightUnit) -> String, - velocity1Rm: VelocityOneRepMaxEntity? = null, ) { - val delta = if (currentOneRepMax != null && previousOneRepMax != null) { - currentOneRepMax - previousOneRepMax + val resolution = (state as? ExerciseDetailOneRepMaxState.Ready)?.resolution + val delta = if ( + resolution?.source == CurrentOneRepMaxSource.SESSION && + previousSessionOneRepMax != null + ) { + resolution.perCableKg - previousSessionOneRepMax } else { null } @@ -312,67 +397,66 @@ private fun OneRepMaxCard( Spacer(Modifier.height(8.dp)) - if (currentOneRepMax != null) { - Text( - formatWeight(currentOneRepMax, weightUnit), - style = MaterialTheme.typography.displayMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onPrimaryContainer, - ) + when { + resolution != null -> { + Text( + formatWeight(resolution.perCableKg, weightUnit), + style = MaterialTheme.typography.displayMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + Text( + when (resolution.source) { + CurrentOneRepMaxSource.VELOCITY -> "Velocity profile" + CurrentOneRepMaxSource.ASSESSMENT -> "Strength assessment" + CurrentOneRepMaxSource.SESSION -> "Recent session" + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.6f), + ) - if (delta != null && delta != 0f) { - Spacer(Modifier.height(8.dp)) - val isPositive = delta > 0 - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - if (isPositive) { - Icons.AutoMirrored.Filled.TrendingUp - } else { - Icons.AutoMirrored.Filled.TrendingDown - }, - contentDescription = null, - tint = if (isPositive) AccessibilityTheme.colors.success else AccessibilityTheme.colors.error, - modifier = Modifier.size(20.dp), - ) - Spacer(Modifier.width(4.dp)) - Text( - "${if (isPositive) "+" else ""}${formatWeight(delta, weightUnit)} from last", - style = MaterialTheme.typography.bodyMedium, - color = if (isPositive) AccessibilityTheme.colors.success else AccessibilityTheme.colors.error, - ) + if (delta != null && delta != 0f) { + Spacer(Modifier.height(8.dp)) + val isPositive = delta > 0 + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + if (isPositive) { + Icons.AutoMirrored.Filled.TrendingUp + } else { + Icons.AutoMirrored.Filled.TrendingDown + }, + contentDescription = null, + tint = if (isPositive) AccessibilityTheme.colors.success else AccessibilityTheme.colors.error, + modifier = Modifier.size(20.dp), + ) + Spacer(Modifier.width(4.dp)) + Text( + "${if (isPositive) "+" else ""}${formatWeight(delta, weightUnit)} from last", + style = MaterialTheme.typography.bodyMedium, + color = if (isPositive) AccessibilityTheme.colors.success else AccessibilityTheme.colors.error, + ) + } } } - } else { - Text( - "No data", - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.5f), - ) - } - // Velocity-based 1RM (issue #517): show only when a passing estimate exists. - velocity1Rm?.let { v -> - Spacer(Modifier.height(16.dp)) - HorizontalDivider(color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.2f)) - Spacer(Modifier.height(12.dp)) - Text( - "VELOCITY 1RM", - style = MaterialTheme.typography.labelMedium, + state is ExerciseDetailOneRepMaxState.Failed -> Text( + "Unable to load 1RM", + style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f), ) - Spacer(Modifier.height(4.dp)) - Text( - formatWeight(v.estimatedPerCableKg, weightUnit), - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onPrimaryContainer, + + state is ExerciseDetailOneRepMaxState.Loading -> Text( + "Loading…", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.5f), ) - Text( - "MVT ${KmpUtils.formatFloat(v.mvtUsedMs, 2)} m/s", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.6f), + + else -> Text( + "No data", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.5f), ) } } @@ -668,14 +752,9 @@ private fun ExerciseHistoryTable(sessions: List, weightUnit: Wei Modifier.weight(1f), ) TableCell( - if (session.workingReps > 0) { - formatWeight( - calculateOneRepMax(session.weightPerCableKg, session.workingReps), - weightUnit, - ) - } else { - "-" - }, + session.estimatedOneRepMaxPerCableOrNull() + ?.let { formatWeight(it, weightUnit) } + ?: "-", Modifier.weight(1f), ) TableCell( @@ -804,8 +883,6 @@ private enum class TimeRange(val label: String) { ALL("All"), } -private fun calculateOneRepMax(weight: Float, reps: Int): Float = OneRepMaxCalculator.estimate(weight, reps) - private fun formatDuration(durationMs: Long): String { val minutes = durationMs / 60000 return if (minutes > 0) "${minutes}min" else "<1min" diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/JustLiftScreen.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/JustLiftScreen.kt index 19fb7b39f..f7514b88b 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/JustLiftScreen.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/JustLiftScreen.kt @@ -63,7 +63,6 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -86,6 +85,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation.NavController import co.touchlab.kermit.Logger +import com.devil.phoenixproject.data.repository.ActiveProfileContext import com.devil.phoenixproject.data.repository.AutoStopUiState import com.devil.phoenixproject.data.repository.UserProfileRepository import com.devil.phoenixproject.domain.model.ConnectionState @@ -101,9 +101,7 @@ import com.devil.phoenixproject.domain.model.WorkoutState import com.devil.phoenixproject.domain.model.eccentricLoadLabel import com.devil.phoenixproject.domain.model.echoLevelLabel import com.devil.phoenixproject.domain.model.toWorkoutMode -import com.devil.phoenixproject.presentation.components.AddProfileDialog import com.devil.phoenixproject.presentation.components.CompactNumberPicker -import com.devil.phoenixproject.presentation.components.ProfileSidePanel import com.devil.phoenixproject.presentation.components.ProgressionSlider import com.devil.phoenixproject.presentation.components.RestTimePickerDialog import com.devil.phoenixproject.presentation.navigation.NavigationRoutes @@ -165,51 +163,51 @@ fun JustLiftScreen(navController: NavController, viewModel: MainViewModel, theme var repCountTiming by remember { mutableStateOf(RepCountTiming.TOP) } var stallDetectionEnabled by rememberSaveable { mutableStateOf(true) } var restSeconds by rememberSaveable { mutableStateOf(60) } // Rest timer between sets (0 = off) - var defaultsLoaded by rememberSaveable { mutableStateOf(false) } - // Profile management - val scope = rememberCoroutineScope() + var defaultsProfileId by remember { mutableStateOf(null) } + // Profile-scoped workout defaults val profileRepository: UserProfileRepository = koinInject() - val profiles by profileRepository.allProfiles.collectAsState() - val activeProfile by profileRepository.activeProfile.collectAsState() - var showAddProfileDialog by remember { mutableStateOf(false) } - - // Load saved Just Lift defaults on screen init - LaunchedEffect(Unit) { - if (!defaultsLoaded) { - val defaults = viewModel.getJustLiftDefaults() - // Apply saved defaults - weightPerCable = defaults.weightPerCableKg - - // Convert stored weight change (KG) to display unit if needed - // weightChangePerRep is already Int in viewmodel format - weightChangePerRep = if (weightUnit == WeightUnit.LB) { - kotlin.math.round(defaults.weightChangePerRep * 2.20462f).toInt() - } else { - defaults.weightChangePerRep - } + val activeProfileContext by profileRepository.activeProfileContext.collectAsState() + val readyProfileId = (activeProfileContext as? ActiveProfileContext.Ready)?.profile?.id + val defaultsLoaded = readyProfileId != null && defaultsProfileId == readyProfileId + + // Reload profile-scoped defaults only when the atomic context is Ready. + LaunchedEffect(readyProfileId) { + val loadingProfileId = readyProfileId ?: return@LaunchedEffect + val loadingContext = activeProfileContext as? ActiveProfileContext.Ready + ?: return@LaunchedEffect + val defaults = viewModel.getJustLiftDefaults() + // Apply saved defaults + weightPerCable = defaults.weightPerCableKg + + // Convert stored weight change (KG) to display unit if needed + // weightChangePerRep is already Int in viewmodel format + weightChangePerRep = if (loadingContext.preferences.core.value.weightUnit == WeightUnit.LB) { + kotlin.math.round(defaults.weightChangePerRep * 2.20462f).toInt() + } else { + defaults.weightChangePerRep + } - // Set mode from saved defaults - val savedProgramMode = defaults.toProgramMode() - selectedMode = savedProgramMode.toWorkoutMode(defaults.getEchoLevel()) + // Set mode from saved defaults + val savedProgramMode = defaults.toProgramMode() + selectedMode = savedProgramMode.toWorkoutMode(defaults.getEchoLevel()) - // Restore eccentric load and echo level for Echo mode - eccentricLoad = defaults.getEccentricLoad() - echoLevel = defaults.getEchoLevel() + // Restore eccentric load and echo level for Echo mode + eccentricLoad = defaults.getEccentricLoad() + echoLevel = defaults.getEchoLevel() - // Restore rep count timing - repCountTiming = defaults.getRepCountTiming() + // Restore rep count timing + repCountTiming = defaults.getRepCountTiming() - // Restore stall detection - stallDetectionEnabled = defaults.stallDetectionEnabled + // Restore stall detection + stallDetectionEnabled = defaults.stallDetectionEnabled - // Restore rest timer duration - restSeconds = defaults.restSeconds + // Restore rest timer duration + restSeconds = defaults.restSeconds - Logger.d( - "Loaded Just Lift defaults: modeId=${defaults.workoutModeId}, weight=${defaults.weightPerCableKg}kg, progression=${defaults.weightChangePerRep}, repTiming=${defaults.repCountTimingName}, restSeconds=${defaults.restSeconds}", - ) - defaultsLoaded = true - } + Logger.d( + "Loaded Just Lift defaults for profile=$loadingProfileId: modeId=${defaults.workoutModeId}, weight=${defaults.weightPerCableKg}kg, progression=${defaults.weightChangePerRep}, repTiming=${defaults.repCountTimingName}, restSeconds=${defaults.restSeconds}", + ) + defaultsProfileId = loadingProfileId } LaunchedEffect(workoutParameters.programMode) { @@ -265,8 +263,8 @@ fun JustLiftScreen(navController: NavController, viewModel: MainViewModel, theme ) { // Don't write params until saved defaults have been loaded. // Without this guard, the effect fires immediately with the hardcoded - // initial weightPerCable (0.453592f) before the defaults-loading - // LaunchedEffect(Unit) has returned from disk, causing #344. + // initial weightPerCable (0.453592f) before the current profile's defaults + // have returned, causing #344 or leaking values across a profile switch. if (!defaultsLoaded) return@LaunchedEffect val weightChangeKg = if (weightUnit == WeightUnit.LB) { @@ -803,23 +801,6 @@ fun JustLiftScreen(navController: NavController, viewModel: MainViewModel, theme ) } - // Profile side panel - ProfileSidePanel( - profiles = profiles, - activeProfile = activeProfile, - profileRepository = profileRepository, - scope = scope, - onAddProfile = { showAddProfileDialog = true }, - ) - // Add Profile Dialog - if (showAddProfileDialog) { - AddProfileDialog( - profiles = profiles, - profileRepository = profileRepository, - scope = scope, - onDismiss = { showAddProfileDialog = false }, - ) - } } } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ProfileScreen.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ProfileScreen.kt new file mode 100644 index 000000000..24b3bb072 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ProfileScreen.kt @@ -0,0 +1,566 @@ +package com.devil.phoenixproject.presentation.screen + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.EmojiEvents +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.devil.phoenixproject.data.repository.ActiveProfileContext +import com.devil.phoenixproject.data.repository.ExerciseRepository +import com.devil.phoenixproject.data.repository.ProfileContextRecoveryException +import com.devil.phoenixproject.data.repository.UserProfile +import com.devil.phoenixproject.presentation.components.ExercisePickerDialog +import com.devil.phoenixproject.presentation.components.AdultsOnlyConfirmDialog +import com.devil.phoenixproject.presentation.components.DiscoModeUnlockDialog +import com.devil.phoenixproject.presentation.components.DominatrixUnlockDialog +import com.devil.phoenixproject.presentation.components.LoadingIndicator +import com.devil.phoenixproject.presentation.components.LoadingIndicatorSize +import com.devil.phoenixproject.presentation.components.ProfileAvatar +import com.devil.phoenixproject.presentation.components.ProfileDeleteDialog +import com.devil.phoenixproject.presentation.components.ProfileEditDialog +import com.devil.phoenixproject.presentation.components.ProfileExerciseInsights +import com.devil.phoenixproject.presentation.components.ProfilePreferenceSections +import com.devil.phoenixproject.presentation.components.canDeleteProfile +import com.devil.phoenixproject.presentation.util.TestTags +import com.devil.phoenixproject.presentation.viewmodel.ProfileIdentityMutationKind +import com.devil.phoenixproject.presentation.viewmodel.ProfilePreferenceMutationKind +import com.devil.phoenixproject.presentation.viewmodel.ProfilePreferenceSection +import com.devil.phoenixproject.presentation.viewmodel.ProfileUiEvent +import com.devil.phoenixproject.presentation.viewmodel.ProfileViewModel +import com.devil.phoenixproject.ui.theme.ThemeMode +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import vitruvianprojectphoenix.shared.generated.resources.* + +internal data class ProfileIdentityOverlayOwnership( + val editTargetProfileId: String? = null, + val deleteTargetProfileId: String? = null, + val pendingIdentityProfileId: String? = null, +) + +private val ProfilePreferenceTokenMapSaver = Saver, List>( + save = { tokens -> + tokens.entries.flatMap { (token, profileId) -> listOf(token.toString(), profileId) } + }, + restore = { saved -> + saved.chunked(2).associate { (token, profileId) -> token.toLong() to profileId } + }, +) + +internal fun retainProfileIdentityOverlayOwnership( + ownership: ProfileIdentityOverlayOwnership, + readyProfileId: String?, +): ProfileIdentityOverlayOwnership { + if (readyProfileId == null) return ownership + return ProfileIdentityOverlayOwnership( + editTargetProfileId = ownership.editTargetProfileId.takeIf { it == readyProfileId }, + deleteTargetProfileId = ownership.deleteTargetProfileId.takeIf { it == readyProfileId }, + pendingIdentityProfileId = ownership.pendingIdentityProfileId.takeIf { + it == readyProfileId + }, + ) +} + +internal data class ProfileIdentityFailureDisposition( + val ownership: ProfileIdentityOverlayOwnership, + val showError: Boolean, +) + +internal fun applyProfileIdentityFailure( + ownership: ProfileIdentityOverlayOwnership, + profileId: String, + kind: ProfileIdentityMutationKind, +): ProfileIdentityFailureDisposition { + val showError = when (kind) { + ProfileIdentityMutationKind.UPDATE -> ownership.editTargetProfileId == profileId + ProfileIdentityMutationKind.DELETE -> ownership.deleteTargetProfileId == profileId + } + return ProfileIdentityFailureDisposition( + ownership = ownership.copy( + pendingIdentityProfileId = ownership.pendingIdentityProfileId.takeUnless { + it == profileId + }, + ), + showError = showError, + ) +} + +@Composable +fun ProfileScreen( + onOpenProfileSwitcher: () -> Unit, + onNavigateToExerciseDetail: (String) -> Unit, + onNavigateToEquipmentRack: () -> Unit, + onNavigateToBadges: () -> Unit, + onProfileRecoveryRequired: (ProfileContextRecoveryException) -> Unit, + isConnected: Boolean, + discoModeActive: Boolean, + onDiscoModeToggle: (Boolean) -> Unit, + onPlayDiscoUnlockSound: () -> Unit, + onPlayDominatrixUnlockSound: () -> Unit, + enableVideoPlayback: Boolean, + themeMode: ThemeMode, + modifier: Modifier = Modifier, + viewModel: ProfileViewModel = koinViewModel(), + exerciseRepository: ExerciseRepository = koinInject(), +) { + val state by viewModel.uiState.collectAsState() + val ready = state.context as? ActiveProfileContext.Ready + val readyProfileId = ready?.profile?.id + var pickerProfileId by rememberSaveable { mutableStateOf(null) } + var editTargetProfileId by rememberSaveable { mutableStateOf(null) } + var deleteTargetProfileId by rememberSaveable { mutableStateOf(null) } + var pendingIdentityProfileId by rememberSaveable { mutableStateOf(null) } + var trackedPreferenceTokens by rememberSaveable( + stateSaver = ProfilePreferenceTokenMapSaver, + ) { mutableStateOf(emptyMap()) } + var lastReadyProfileId by rememberSaveable { mutableStateOf(null) } + var showAdultsOnlyDialog by rememberSaveable { mutableStateOf(false) } + var adultTargetProfileId by rememberSaveable { mutableStateOf(null) } + var adultPreferenceToken by rememberSaveable { mutableStateOf(null) } + var adultPreferenceError by rememberSaveable { mutableStateOf(null) } + var showDiscoUnlockDialog by rememberSaveable { mutableStateOf(false) } + var showDominatrixUnlockDialog by rememberSaveable { mutableStateOf(false) } + val snackbarHostState = remember { SnackbarHostState() } + val currentOnProfileRecoveryRequired by rememberUpdatedState(onProfileRecoveryRequired) + val onPlayDiscoUnlockSound by rememberUpdatedState(onPlayDiscoUnlockSound) + val onPlayDominatrixUnlockSound by rememberUpdatedState( + onPlayDominatrixUnlockSound, + ) + val updateFailedMessage = stringResource(Res.string.profile_update_failed) + val adultPartialFailureMessage = + stringResource(Res.string.profile_adult_enable_partial_failure) + + fun trackPreferenceToken(token: Long?): Long? { + val profileId = readyProfileId ?: return null + if (token != null) trackedPreferenceTokens = trackedPreferenceTokens + (token to profileId) + return token + } + + LaunchedEffect(readyProfileId) { + if (pickerProfileId != readyProfileId) pickerProfileId = null + val retained = retainProfileIdentityOverlayOwnership( + ownership = ProfileIdentityOverlayOwnership( + editTargetProfileId = editTargetProfileId, + deleteTargetProfileId = deleteTargetProfileId, + pendingIdentityProfileId = pendingIdentityProfileId, + ), + readyProfileId = readyProfileId, + ) + editTargetProfileId = retained.editTargetProfileId + deleteTargetProfileId = retained.deleteTargetProfileId + pendingIdentityProfileId = retained.pendingIdentityProfileId + if ( + readyProfileId != null && + lastReadyProfileId != null && + lastReadyProfileId != readyProfileId + ) { + trackedPreferenceTokens = emptyMap() + showAdultsOnlyDialog = false + adultTargetProfileId = null + adultPreferenceToken = null + adultPreferenceError = null + showDiscoUnlockDialog = false + showDominatrixUnlockDialog = false + } + if (readyProfileId != null) lastReadyProfileId = readyProfileId + } + + LaunchedEffect( + viewModel, + readyProfileId, + snackbarHostState, + updateFailedMessage, + adultPartialFailureMessage, + ) { + viewModel.events.collect { event -> + when (event) { + is ProfileUiEvent.IdentityUpdated -> { + if (editTargetProfileId == event.profileId) editTargetProfileId = null + if (pendingIdentityProfileId == event.profileId) pendingIdentityProfileId = null + } + is ProfileUiEvent.IdentityUpdateFailed -> { + val failure = applyProfileIdentityFailure( + ownership = ProfileIdentityOverlayOwnership( + editTargetProfileId = editTargetProfileId, + deleteTargetProfileId = deleteTargetProfileId, + pendingIdentityProfileId = pendingIdentityProfileId, + ), + profileId = event.profileId, + kind = event.kind, + ) + editTargetProfileId = failure.ownership.editTargetProfileId + deleteTargetProfileId = failure.ownership.deleteTargetProfileId + pendingIdentityProfileId = failure.ownership.pendingIdentityProfileId + if (failure.showError) snackbarHostState.showSnackbar(updateFailedMessage) + } + is ProfileUiEvent.ProfileDeleted -> { + if (deleteTargetProfileId == event.profileId) deleteTargetProfileId = null + if (pendingIdentityProfileId == event.profileId) pendingIdentityProfileId = null + } + is ProfileUiEvent.ProfileRecoveryRequired -> { + if (pendingIdentityProfileId == event.profileId) { + editTargetProfileId = null + deleteTargetProfileId = null + pendingIdentityProfileId = null + currentOnProfileRecoveryRequired(event.cause) + } + } + is ProfileUiEvent.PreferenceMutationSucceeded -> { + if (trackedPreferenceTokens.containsKey(event.token)) { + val ownedProfileId = trackedPreferenceTokens[event.token] + trackedPreferenceTokens = trackedPreferenceTokens - event.token + if (adultPreferenceToken == event.token) adultPreferenceToken = null + if ( + event.profileId == readyProfileId && + ownedProfileId == event.profileId + ) { + when (event.kind) { + ProfilePreferenceMutationKind.UPDATE -> Unit + ProfilePreferenceMutationKind.ADULT_ENABLE, + ProfilePreferenceMutationKind.ADULT_DECLINE + -> { + showAdultsOnlyDialog = false + adultTargetProfileId = null + adultPreferenceError = null + } + ProfilePreferenceMutationKind.DISCO_UNLOCK -> { + onPlayDiscoUnlockSound() + showDiscoUnlockDialog = true + } + ProfilePreferenceMutationKind.DOMINATRIX_UNLOCK -> { + onPlayDominatrixUnlockSound() + showDominatrixUnlockDialog = true + } + } + } + } + } + is ProfileUiEvent.PreferenceUpdateFailed -> { + if (trackedPreferenceTokens.containsKey(event.token)) { + val ownedProfileId = trackedPreferenceTokens[event.token] + trackedPreferenceTokens = trackedPreferenceTokens - event.token + if (adultPreferenceToken == event.token) adultPreferenceToken = null + if ( + event.profileId == readyProfileId && + ownedProfileId == event.profileId + ) { + when (event.kind) { + ProfilePreferenceMutationKind.ADULT_ENABLE, + ProfilePreferenceMutationKind.ADULT_DECLINE + -> { + adultPreferenceError = if ( + ProfilePreferenceSection.LOCAL_SAFETY in event.committedSections + ) { + adultPartialFailureMessage + } else { + updateFailedMessage + } + } + ProfilePreferenceMutationKind.UPDATE, + ProfilePreferenceMutationKind.DISCO_UNLOCK, + ProfilePreferenceMutationKind.DOMINATRIX_UNLOCK + -> snackbarHostState.showSnackbar(updateFailedMessage) + } + } + } + } + } + } + } + + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, + contentWindowInsets = WindowInsets(0, 0, 0, 0), + modifier = modifier.testTag(TestTags.SCREEN_PROFILE), + ) { padding -> + if (ready == null) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + LoadingIndicator(LoadingIndicatorSize.Large) + Text( + text = stringResource(Res.string.profile_switching), + modifier = Modifier.padding(top = 12.dp), + ) + } + } else { + LazyColumn( + contentPadding = PaddingValues( + start = 16.dp, + top = padding.calculateTopPadding() + 12.dp, + end = 16.dp, + bottom = padding.calculateBottomPadding() + 24.dp, + ), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item(key = "profile-header") { + ProfileHeaderCard( + profile = ready.profile, + identityMutationInFlight = state.identityMutationInFlight, + onSwitchProfile = onOpenProfileSwitcher, + onEdit = { editTargetProfileId = ready.profile.id }, + onDelete = { deleteTargetProfileId = ready.profile.id }, + ) + } + item(key = "exercise-insights") { + ProfileExerciseInsights( + selectedExercise = state.selectedExercise, + missingExerciseId = state.missingExerciseId, + selectionFailure = state.selectionFailure, + currentOneRepMax = state.currentOneRepMax, + prHighlights = state.prHighlights, + recentSessions = state.recentSessions, + weightUnit = ready.preferences.core.value.weightUnit, + onChooseExercise = { pickerProfileId = ready.profile.id }, + onViewFullHistory = onNavigateToExerciseDetail, + ) + } + item(key = "achievements") { + Card( + modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp) + .testTag(TestTags.ACTION_BADGES), + onClick = onNavigateToBadges, + ) { + Row( + modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + Icons.Default.EmojiEvents, + contentDescription = stringResource(Res.string.cd_achievements), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + stringResource(Res.string.cd_achievements), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + } + } + item(key = "preferences-heading") { + Text( + stringResource(Res.string.profile_preferences_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + } + item(key = "profile-preferences") { + ProfilePreferenceSections( + profileId = ready.profile.id, + preferences = ready.preferences, + localSafety = ready.localSafety, + importedBodyWeightMeasuredAt = state.importedBodyWeightMeasuredAt, + busySections = state.busyPreferenceSections, + isConnected = isConnected, + discoModeActive = discoModeActive, + onCoreChange = { trackPreferenceToken(viewModel.updateCore(it)) }, + onWorkoutChange = { trackPreferenceToken(viewModel.updateWorkout(it)) }, + onLedChange = { trackPreferenceToken(viewModel.updateLed(it)) }, + onVbtChange = { trackPreferenceToken(viewModel.updateVbt(it)) }, + onLocalSafetyChange = { + trackPreferenceToken(viewModel.updateLocalSafety(it)) + }, + onRequestAdultsOnlyConfirmation = { + adultTargetProfileId = ready.profile.id + adultPreferenceError = null + showAdultsOnlyDialog = true + }, + onUnlockDiscoMode = { + trackPreferenceToken(viewModel.unlockDiscoMode()) + }, + onUnlockDominatrixMode = { + trackPreferenceToken(viewModel.unlockDominatrixMode()) + }, + onManageEquipmentRack = onNavigateToEquipmentRack, + onDiscoModeToggle = onDiscoModeToggle, + ) + } + } + } + } + + val editTarget = ready?.profile?.takeIf { it.id == editTargetProfileId } + if (editTarget != null) { + ProfileEditDialog( + profile = editTarget, + isSubmitting = state.identityMutationInFlight, + onConfirm = { name, colorIndex -> + pendingIdentityProfileId = editTarget.id + viewModel.updateIdentity(name, colorIndex) + }, + onDismiss = { + if (!state.identityMutationInFlight) editTargetProfileId = null + }, + ) + } + + val deleteTarget = ready?.profile?.takeIf { it.id == deleteTargetProfileId } + if (deleteTarget != null && canDeleteProfile(deleteTarget)) { + ProfileDeleteDialog( + profile = deleteTarget, + isSubmitting = state.identityMutationInFlight, + onConfirm = { + pendingIdentityProfileId = deleteTarget.id + viewModel.deleteActiveProfile() + }, + onDismiss = { + if (!state.identityMutationInFlight) deleteTargetProfileId = null + }, + ) + } + + ExercisePickerDialog( + showDialog = pickerProfileId == ready?.profile?.id, + onDismiss = { pickerProfileId = null }, + onExerciseSelected = { exercise -> + pickerProfileId = null + viewModel.selectExercise(exercise) + }, + exerciseRepository = exerciseRepository, + enableVideoPlayback = enableVideoPlayback, + themeMode = themeMode, + enableCustomExercises = false, + ) + + if (showAdultsOnlyDialog && adultTargetProfileId == ready?.profile?.id) { + AdultsOnlyConfirmDialog( + isSubmitting = adultPreferenceToken?.let(trackedPreferenceTokens::containsKey) == true, + errorMessage = adultPreferenceError, + onConfirm = { + adultPreferenceError = null + adultPreferenceToken = trackPreferenceToken( + viewModel.confirmAdultsOnlyAndEnableVulgar(), + ) + }, + onDecline = { + adultPreferenceError = null + adultPreferenceToken = trackPreferenceToken(viewModel.declineAdultsOnly()) + }, + onDismiss = { + showAdultsOnlyDialog = false + adultTargetProfileId = null + adultPreferenceError = null + }, + ) + } + + if (showDiscoUnlockDialog) { + DiscoModeUnlockDialog(onDismiss = { showDiscoUnlockDialog = false }) + } + + if (showDominatrixUnlockDialog) { + DominatrixUnlockDialog(onDismiss = { showDominatrixUnlockDialog = false }) + } +} + +@Composable +private fun ProfileHeaderCard( + profile: UserProfile, + identityMutationInFlight: Boolean, + onSwitchProfile: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit, +) { + val switchLabel = stringResource(Res.string.switch_profile) + val editLabel = stringResource(Res.string.action_edit) + val deleteLabel = stringResource(Res.string.action_delete) + Card(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + ProfileAvatar(profile = profile, isActive = true, size = 56.dp) + Column(modifier = Modifier.weight(1f)) { + Text( + text = profile.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + TextButton( + onClick = onSwitchProfile, + enabled = !identityMutationInFlight, + modifier = Modifier + .heightIn(min = 48.dp) + .semantics { + contentDescription = switchLabel + role = Role.Button + }, + ) { + Text(switchLabel) + } + } + IconButton( + onClick = onEdit, + enabled = !identityMutationInFlight, + modifier = Modifier + .size(48.dp) + .testTag(TestTags.ACTION_EDIT_PROFILE), + ) { + Icon(Icons.Default.Edit, contentDescription = editLabel) + } + if (canDeleteProfile(profile)) { + IconButton( + onClick = onDelete, + enabled = !identityMutationInFlight, + modifier = Modifier + .size(48.dp) + .testTag(TestTags.ACTION_DELETE_PROFILE), + ) { + Icon(Icons.Default.Delete, contentDescription = deleteLabel) + } + } + } + } +} diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/SettingsTab.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/SettingsTab.kt index 76ea38bfb..90261d5a4 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/SettingsTab.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/SettingsTab.kt @@ -1,70 +1,45 @@ package com.devil.phoenixproject.presentation.screen -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.snap -import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.BugReport -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.CloudDownload import androidx.compose.material.icons.filled.CloudUpload -import androidx.compose.material.icons.filled.ColorLens import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.DeleteForever -import androidx.compose.material.icons.filled.EmojiEvents import androidx.compose.material.icons.filled.Extension import androidx.compose.material.icons.filled.Favorite -import androidx.compose.material.icons.filled.FitnessCenter import androidx.compose.material.icons.filled.FolderOpen import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Language -import androidx.compose.material.icons.filled.MilitaryTech import androidx.compose.material.icons.filled.MusicNote import androidx.compose.material.icons.filled.Palette -import androidx.compose.material.icons.filled.PowerSettingsNew import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.filled.Scale import androidx.compose.material.icons.filled.Share -import androidx.compose.material.icons.filled.Speed import androidx.compose.material.icons.filled.Sync import androidx.compose.material.icons.filled.Timeline import androidx.compose.material.icons.filled.Tune import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.AlertDialog -import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card @@ -76,25 +51,19 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuAnchorType import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults -import androidx.compose.material3.FilterChip -import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.RadioButton import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRow -import androidx.compose.material3.Slider -import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -104,48 +73,28 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.rotate -import androidx.compose.ui.draw.scale import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp -import com.devil.phoenixproject.data.integration.ExternalMeasurementRepository -import com.devil.phoenixproject.data.integration.HealthBodyWeightSyncManager -import com.devil.phoenixproject.data.repository.UserProfileRepository import com.devil.phoenixproject.data.sync.SyncTriggerManager import com.devil.phoenixproject.domain.model.BleCompatibilitySetting -import com.devil.phoenixproject.domain.model.IntegrationProvider -import com.devil.phoenixproject.domain.model.ScalingBasis -import com.devil.phoenixproject.domain.model.VulgarTier -import com.devil.phoenixproject.domain.model.WeightUnit -import com.devil.phoenixproject.presentation.components.ConfirmEditTextField -import com.devil.phoenixproject.presentation.components.CountdownDropdown -import com.devil.phoenixproject.presentation.util.LocalPlatformAccessibilitySettings import com.devil.phoenixproject.presentation.components.DestructiveConfirmDialog -import com.devil.phoenixproject.presentation.components.ExpressiveSlider import com.devil.phoenixproject.ui.theme.* import com.devil.phoenixproject.util.BackupDestination import com.devil.phoenixproject.util.BackupProgress -import com.devil.phoenixproject.util.ColorSchemes -import com.devil.phoenixproject.util.Constants +import com.devil.phoenixproject.util.BackupStats import com.devil.phoenixproject.util.DataBackupManager import com.devil.phoenixproject.util.DeviceInfo import com.devil.phoenixproject.util.ImportResult -import com.devil.phoenixproject.util.KmpUtils -import com.devil.phoenixproject.util.UnitConverter import com.devil.phoenixproject.util.rememberBackupLocationPicker import com.devil.phoenixproject.util.rememberFilePicker -import kotlin.math.abs -import kotlin.math.roundToInt import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject @@ -160,36 +109,24 @@ import vitruvianprojectphoenix.shared.generated.resources.action_share import vitruvianprojectphoenix.shared.generated.resources.backup_all_data import vitruvianprojectphoenix.shared.generated.resources.backup_description import vitruvianprojectphoenix.shared.generated.resources.backup_success -import vitruvianprojectphoenix.shared.generated.resources.cd_achievements -import vitruvianprojectphoenix.shared.generated.resources.cd_advanced_settings import vitruvianprojectphoenix.shared.generated.resources.cd_app_info import vitruvianprojectphoenix.shared.generated.resources.cd_appearance import vitruvianprojectphoenix.shared.generated.resources.cd_backup_data -import vitruvianprojectphoenix.shared.generated.resources.cd_calibration_check import vitruvianprojectphoenix.shared.generated.resources.cd_cloud_sync import vitruvianprojectphoenix.shared.generated.resources.cd_connection_logs import vitruvianprojectphoenix.shared.generated.resources.cd_delete_workouts import vitruvianprojectphoenix.shared.generated.resources.cd_developer_tools import vitruvianprojectphoenix.shared.generated.resources.cd_dynamic_color -import vitruvianprojectphoenix.shared.generated.resources.cd_equipment_rack -import vitruvianprojectphoenix.shared.generated.resources.cd_led_scheme -import vitruvianprojectphoenix.shared.generated.resources.cd_leds_off import vitruvianprojectphoenix.shared.generated.resources.cd_link_portal import vitruvianprojectphoenix.shared.generated.resources.cd_open_backup_folder import vitruvianprojectphoenix.shared.generated.resources.cd_restore_data import vitruvianprojectphoenix.shared.generated.resources.cd_support_developer import vitruvianprojectphoenix.shared.generated.resources.cd_sync_error import vitruvianprojectphoenix.shared.generated.resources.cd_test_sounds -import vitruvianprojectphoenix.shared.generated.resources.cd_view_badges -import vitruvianprojectphoenix.shared.generated.resources.cd_weight_unit import vitruvianprojectphoenix.shared.generated.resources.diagnostics_title -import vitruvianprojectphoenix.shared.generated.resources.equipment_rack_description -import vitruvianprojectphoenix.shared.generated.resources.equipment_rack_title import vitruvianprojectphoenix.shared.generated.resources.import_completed import vitruvianprojectphoenix.shared.generated.resources.import_records_imported import vitruvianprojectphoenix.shared.generated.resources.import_records_skipped -import vitruvianprojectphoenix.shared.generated.resources.label_kg -import vitruvianprojectphoenix.shared.generated.resources.label_lbs import vitruvianprojectphoenix.shared.generated.resources.label_please_wait import vitruvianprojectphoenix.shared.generated.resources.language_dutch import vitruvianprojectphoenix.shared.generated.resources.language_english @@ -207,16 +144,6 @@ import vitruvianprojectphoenix.shared.generated.resources.settings_ble_compat_of import vitruvianprojectphoenix.shared.generated.resources.settings_ble_compat_on import vitruvianprojectphoenix.shared.generated.resources.settings_ble_compat_reconnect_hint import vitruvianprojectphoenix.shared.generated.resources.settings_ble_compat_title -import vitruvianprojectphoenix.shared.generated.resources.settings_calibrate_button -import vitruvianprojectphoenix.shared.generated.resources.settings_calibrate_first -import vitruvianprojectphoenix.shared.generated.resources.settings_calibrated_badge -import vitruvianprojectphoenix.shared.generated.resources.settings_calibration_fail -import vitruvianprojectphoenix.shared.generated.resources.settings_calibration_listening -import vitruvianprojectphoenix.shared.generated.resources.settings_calibration_mic_error -import vitruvianprojectphoenix.shared.generated.resources.settings_calibration_open_settings -import vitruvianprojectphoenix.shared.generated.resources.settings_calibration_progress -import vitruvianprojectphoenix.shared.generated.resources.settings_calibration_prompt -import vitruvianprojectphoenix.shared.generated.resources.settings_calibration_title import vitruvianprojectphoenix.shared.generated.resources.settings_cloud_sync import vitruvianprojectphoenix.shared.generated.resources.settings_sync_error_tap_to_dismiss import vitruvianprojectphoenix.shared.generated.resources.settings_dynamic_color @@ -224,38 +151,16 @@ import vitruvianprojectphoenix.shared.generated.resources.settings_dynamic_color import vitruvianprojectphoenix.shared.generated.resources.settings_language import vitruvianprojectphoenix.shared.generated.resources.settings_language_help import vitruvianprojectphoenix.shared.generated.resources.settings_machine_diagnostics_description -import vitruvianprojectphoenix.shared.generated.resources.settings_safe_word_hint -import vitruvianprojectphoenix.shared.generated.resources.settings_safe_word_label +import vitruvianprojectphoenix.shared.generated.resources.settings_show_exercise_videos +import vitruvianprojectphoenix.shared.generated.resources.settings_show_exercise_videos_description import vitruvianprojectphoenix.shared.generated.resources.settings_theme_dark import vitruvianprojectphoenix.shared.generated.resources.settings_theme_light import vitruvianprojectphoenix.shared.generated.resources.settings_theme_mode import vitruvianprojectphoenix.shared.generated.resources.settings_theme_mode_description import vitruvianprojectphoenix.shared.generated.resources.settings_theme_system import vitruvianprojectphoenix.shared.generated.resources.settings_title +import vitruvianprojectphoenix.shared.generated.resources.settings_video_behavior import vitruvianprojectphoenix.shared.generated.resources.settings_version -import vitruvianprojectphoenix.shared.generated.resources.settings_voice_stop_description -import vitruvianprojectphoenix.shared.generated.resources.settings_voice_stop_title -import vitruvianprojectphoenix.shared.generated.resources.settings_weight_suggestions_description -import vitruvianprojectphoenix.shared.generated.resources.settings_weight_suggestions_title -import vitruvianprojectphoenix.shared.generated.resources.settings_weight_unit -import vitruvianprojectphoenix.shared.generated.resources.settings_verbal_encouragement_title -import vitruvianprojectphoenix.shared.generated.resources.settings_verbal_encouragement_description -import vitruvianprojectphoenix.shared.generated.resources.settings_vulgar_mode_title -import vitruvianprojectphoenix.shared.generated.resources.settings_vulgar_mode_description -import vitruvianprojectphoenix.shared.generated.resources.settings_vulgar_mode_headphone_warning -import vitruvianprojectphoenix.shared.generated.resources.settings_vulgar_mode_tier_label -import vitruvianprojectphoenix.shared.generated.resources.settings_vulgar_mode_tier_mild -import vitruvianprojectphoenix.shared.generated.resources.settings_vulgar_mode_tier_strong -import vitruvianprojectphoenix.shared.generated.resources.settings_vulgar_mode_tier_mix -import vitruvianprojectphoenix.shared.generated.resources.settings_dominatrix_mode_title -import vitruvianprojectphoenix.shared.generated.resources.settings_dominatrix_mode_description -import vitruvianprojectphoenix.shared.generated.resources.settings_dominatrix_unlock_hint -import vitruvianprojectphoenix.shared.generated.resources.settings_dominatrix_unlock_toast -import vitruvianprojectphoenix.shared.generated.resources.settings_adults_only_title -import vitruvianprojectphoenix.shared.generated.resources.settings_adults_only_body -import vitruvianprojectphoenix.shared.generated.resources.settings_adults_only_compliance_footer -import vitruvianprojectphoenix.shared.generated.resources.settings_adults_only_confirm -import vitruvianprojectphoenix.shared.generated.resources.settings_adults_only_decline @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -301,139 +206,93 @@ private fun ThemeModeSelector( } } +@Composable +private fun GlobalSettingsSectionCard( + title: String, + icon: ImageVector, + content: @Composable ColumnScope.() -> Unit, +) { + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon(imageVector = icon, contentDescription = null) + Text(text = title, style = MaterialTheme.typography.titleMedium) + } + Spacer(modifier = Modifier.height(8.dp)) + content() + } + } +} + +@Composable +private fun GlobalSettingsSwitchRow( + title: String, + description: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .defaultMinSize(minHeight = 48.dp) + .toggleable( + value = checked, + role = Role.Switch, + onValueChange = onCheckedChange, + ) + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text(text = title, style = MaterialTheme.typography.bodyLarge) + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch(checked = checked, onCheckedChange = null) + } +} + @Composable fun SettingsTab( - weightUnit: WeightUnit, enableVideoPlayback: Boolean, themeMode: ThemeMode, - dynamicColorAvailable: Boolean = false, - dynamicColorEnabled: Boolean = false, - audioRepCountEnabled: Boolean = false, - // Issue #100: Per-sound toggles - countdownBeepsEnabled: Boolean = true, - repSoundEnabled: Boolean = true, - onCountdownBeepsChange: (Boolean) -> Unit = {}, - onRepSoundChange: (Boolean) -> Unit = {}, - // Issue #237: Motion-triggered set start - motionStartEnabled: Boolean = false, - onMotionStartChange: (Boolean) -> Unit = {}, - // Issue #190: Auto-start routine (skip overview) - autoStartRoutine: Boolean = false, - onAutoStartRoutineChange: (Boolean) -> Unit = {}, - weightSuggestionsEnabled: Boolean = true, - onWeightSuggestionsEnabledChange: (Boolean) -> Unit = {}, - summaryCountdownSeconds: Int = 10, - autoStartCountdownSeconds: Int = 5, - selectedColorSchemeIndex: Int = 0, - onWeightUnitChange: (WeightUnit) -> Unit, + dynamicColorAvailable: Boolean, + dynamicColorEnabled: Boolean, onEnableVideoPlaybackChange: (Boolean) -> Unit, onThemeModeChange: (ThemeMode) -> Unit, - onDynamicColorEnabledChange: (Boolean) -> Unit = {}, - onAudioRepCountChange: (Boolean) -> Unit, - onSummaryCountdownChange: (Int) -> Unit = {}, - onAutoStartCountdownChange: (Int) -> Unit = {}, - onColorSchemeChange: (Int) -> Unit, + onDynamicColorEnabledChange: (Boolean) -> Unit, onDeleteAllWorkouts: () -> Unit, - onNavigateToConnectionLogs: () -> Unit = {}, - onNavigateToDiagnostics: () -> Unit = {}, - onNavigateToBadges: () -> Unit = {}, - onNavigateToLinkAccount: () -> Unit = {}, - onNavigateToIntegrations: () -> Unit = {}, - onNavigateToEquipmentRack: () -> Unit = {}, - connectionError: String? = null, - onClearConnectionError: () -> Unit = {}, - onCancelAutoConnecting: () -> Unit = {}, + onNavigateToConnectionLogs: () -> Unit, + onNavigateToDiagnostics: () -> Unit, + onNavigateToLinkAccount: () -> Unit, + onNavigateToIntegrations: () -> Unit, + connectionError: String?, + onClearConnectionError: () -> Unit, onSetTitle: (String) -> Unit, - // Disco mode Easter egg - discoModeUnlocked: Boolean = false, - discoModeActive: Boolean = false, - isConnected: Boolean = false, - onDiscoModeUnlocked: () -> Unit = {}, - onDiscoModeToggle: (Boolean) -> Unit = {}, - onPlayDiscoSound: () -> Unit = {}, - onTestSounds: () -> Unit = {}, - // Gamification toggle - gamificationEnabled: Boolean = true, - onGamificationEnabledChange: (Boolean) -> Unit = {}, - // Issue #333: BLE small-MTU compatibility path (Auto = on for Pixel 6/7 family) - bleCompatibilityMode: BleCompatibilitySetting = BleCompatibilitySetting.AUTO, - onBleCompatibilityModeChange: (BleCompatibilitySetting) -> Unit = {}, - // Auto-backup (Phase 36) - autoBackupEnabled: Boolean = false, - onAutoBackupEnabledChange: (Boolean) -> Unit = {}, - backupStats: com.devil.phoenixproject.util.BackupStats? = null, - onOpenBackupFolder: () -> Unit = {}, - // Custom backup destination (Phase 42) - backupDestination: com.devil.phoenixproject.util.BackupDestination = com.devil.phoenixproject.util.BackupDestination.Default, - onBackupDestinationChange: (com.devil.phoenixproject.util.BackupDestination) -> Unit = {}, - // Issue #238: Language preference - selectedLanguage: String = "en", - onLanguageChange: (String) -> Unit = {}, - // Issue #141: Voice emergency stop - voiceStopEnabled: Boolean = false, - onVoiceStopEnabledChange: (Boolean) -> Unit = {}, - safeWord: String? = null, - onSafeWordChange: (String?) -> Unit = {}, - safeWordCalibrated: Boolean = false, - onSafeWordCalibratedChange: (Boolean) -> Unit = {}, - // Issue #266: Configurable weight increment - weightIncrement: Float = -1f, - onWeightIncrementChange: (Float) -> Unit = {}, - // Issue #229: Body weight for bodyweight exercise volume - bodyWeightKg: Float = 0f, - onBodyWeightKgChange: (Float) -> Unit = {}, - // Issue #313: VBT power loss threshold - velocityLossThresholdPercent: Int = 20, - onVelocityLossThresholdChange: (Int) -> Unit = {}, - autoEndOnVelocityLoss: Boolean = false, - onAutoEndOnVelocityLossChange: (Boolean) -> Unit = {}, - // Issue #517: System-wide default scaling basis for % of 1RM - defaultScalingBasis: ScalingBasis = ScalingBasis.MAX_WEIGHT_PR, - onDefaultScalingBasisChange: (ScalingBasis) -> Unit = {}, - // Issue #595: Routine-builder defaults for newly added cable exercises - defaultRoutineExerciseUsePercentOfPR: Boolean = false, - defaultRoutineExerciseWeightPercentOfPR: Int = 80, - onDefaultRoutineExerciseUsePercentOfPRChange: (Boolean) -> Unit = {}, - onDefaultRoutineExerciseWeightPercentOfPRChange: (Int) -> Unit = {}, - // Issue #611: Verbal encouragement + opt-in vulgar mode + Dominatrix mode + 18+ gate - verbalEncouragementEnabled: Boolean = false, - onVerbalEncouragementEnabledChange: (Boolean) -> Unit = {}, - vulgarModeEnabled: Boolean = false, - onVulgarModeEnabledChange: (Boolean) -> Unit = {}, - vulgarTier: VulgarTier = VulgarTier.STRONG, - onVulgarTierChange: (VulgarTier) -> Unit = {}, - dominatrixModeUnlocked: Boolean = false, - onDominatrixModeUnlockedChange: (Boolean) -> Unit = {}, - dominatrixModeActive: Boolean = false, - onDominatrixModeActiveChange: (Boolean) -> Unit = {}, - adultsOnlyConfirmed: Boolean = false, - onAdultsOnlyConfirmedChange: (Boolean) -> Unit = {}, - // Issue #611 (PR-followup #613): one-shot 18+ modal gate reader + writer. Lives - // outside UserPreferences by design (architecture §3); read on every vulgar-on - // toggle, written once per install by either confirm OR decline callback. - adultsOnlyPrompted: Boolean = false, - onAdultsOnlyPromptedChange: (Boolean) -> Unit = {}, - onPlayDominatrixUnlockSound: () -> Unit = {}, + onTestSounds: () -> Unit, + bleCompatibilityMode: BleCompatibilitySetting, + onBleCompatibilityModeChange: (BleCompatibilitySetting) -> Unit, + autoBackupEnabled: Boolean, + onAutoBackupEnabledChange: (Boolean) -> Unit, + backupStats: BackupStats?, + onOpenBackupFolder: () -> Unit, + backupDestination: BackupDestination, + onBackupDestinationChange: (BackupDestination) -> Unit, + selectedLanguage: String, + onLanguageChange: (String) -> Unit, modifier: Modifier = Modifier, ) { - val focusManager = LocalFocusManager.current - val userProfileRepository = koinInject() - val externalMeasurementRepository = koinInject() - val activeProfile by userProfileRepository.activeProfile.collectAsState() - val activeProfileId = activeProfile?.id ?: "default" - val healthBodyWeightMeasurements by remember(activeProfileId) { - externalMeasurementRepository.observeMeasurementsByType( - profileId = activeProfileId, - measurementType = HealthBodyWeightSyncManager.MEASUREMENT_TYPE_WEIGHT, - ) - }.collectAsState(initial = emptyList()) - val latestMatchingHealthBodyWeight = healthBodyWeightMeasurements - .filter { measurement -> - measurement.unit == HealthBodyWeightSyncManager.UNIT_KG && - measurement.provider in setOf(IntegrationProvider.APPLE_HEALTH, IntegrationProvider.GOOGLE_HEALTH) && - abs(measurement.value.toFloat() - bodyWeightKg) < 0.05f - } - .maxByOrNull { it.measuredAt } var showDeleteAllDialog by remember { mutableStateOf(false) } // Backup/Restore state var showBackupDialog by remember { mutableStateOf(false) } @@ -447,41 +306,6 @@ fun SettingsTab( var showResultDialog by remember { mutableStateOf(false) } var launchFilePicker by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() - // Easter egg tap counter for disco mode - var easterEggTapCount by remember { mutableStateOf(0) } - var lastTapTime by remember { mutableStateOf(0L) } - // Disco mode unlock celebration dialog - var showDiscoUnlockDialog by remember { mutableStateOf(false) } - // Issue #611: Gated 7-tap counter for Dominatrix easter egg on the VBT card header. - // Only counts when vulgarModeEnabled=true; otherwise clickable is a no-op. - var dominatrixEasterEggTapCount by remember { mutableStateOf(0) } - var lastDominatrixTapTime by remember { mutableStateOf(0L) } - var showDominatrixUnlockDialog by remember { mutableStateOf(false) } - // Issue #611: 18+ Adults Only modal state. Fires once per install on first vulgar-on. - var showAdultsOnlyDialog by remember { mutableStateOf(false) } - // Voice emergency stop state (moved from VoiceEmergencyStopSection for consolidation) - var showCalibrationDialog by remember { mutableStateOf(false) } - var localSafeWord by remember(safeWord) { mutableStateOf(safeWord ?: "") } - // Optimistic UI state for immediate visual feedback - var localWeightUnit by remember(weightUnit) { mutableStateOf(weightUnit) } - // Issue #266: Weight increment picker dialog state - var showWeightIncrementDialog by remember { mutableStateOf(false) } - // Issue #229: Body weight input dialog state - var showBodyWeightDialog by remember { mutableStateOf(false) } - var bodyWeightInput by remember(bodyWeightKg) { - mutableStateOf( - if (bodyWeightKg > 0f) { - if (weightUnit == WeightUnit.KG) { - UnitConverter.formatDecimal(bodyWeightKg) - } else { - UnitConverter.formatDecimal(UnitConverter.kgToLb(bodyWeightKg)) - } - } else { - "" - }, - ) - } - // Inject DataBackupManager for manual backup/restore operations val backupManager: DataBackupManager = koinInject() // Inject SyncTriggerManager for sync error indicator @@ -709,361 +533,6 @@ fun SettingsTab( } } - // Weight Unit Section - Material 3 Expressive - Card( - modifier = Modifier - .fillMaxWidth() - .shadow(8.dp, MaterialTheme.shapes.medium), // Material 3 Expressive: More shadow, more rounded - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHighest), // Material 3 Expressive: Higher contrast - shape = MaterialTheme.shapes.medium, // Material 3 Expressive: More rounded (was 16dp) - elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), // Material 3 Expressive: Higher elevation (was 4dp) - border = BorderStroke(2.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)), // Material 3 Expressive: Thicker border (was 1dp) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(Spacing.medium), - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Box( - modifier = Modifier - .size(48.dp) // Material 3 Expressive: Larger (was 40dp) - .shadow(8.dp, MaterialTheme.shapes.medium) // Material 3 Expressive: More shadow, more rounded (was 16dp) - .background( - Brush.linearGradient( - colors = listOf(Color(0xFF8B5CF6), Color(0xFF9333EA)), - ), - MaterialTheme.shapes.medium, // Material 3 Expressive: More rounded (was 16dp) - ), - contentAlignment = Alignment.Center, - ) { - Icon( - Icons.Default.Scale, - contentDescription = stringResource(Res.string.cd_weight_unit), - tint = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier.size(24.dp), // Material 3 Expressive: Larger icon - ) - } - Spacer(modifier = Modifier.width(Spacing.medium)) - Text( - stringResource(Res.string.settings_weight_unit), - style = MaterialTheme.typography.titleLarge, // Material 3 Expressive: Larger (was titleMedium) - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface, - ) - } - Spacer(modifier = Modifier.height(Spacing.small)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(Spacing.small), - ) { - FilterChip( - selected = localWeightUnit == WeightUnit.KG, - onClick = { - val changed = localWeightUnit != WeightUnit.KG - localWeightUnit = WeightUnit.KG - onWeightUnitChange(WeightUnit.KG) - // Reset increment to default when unit changes (options differ per system) - if (changed) onWeightIncrementChange(-1f) - }, - label = { Text(stringResource(Res.string.label_kg)) }, - modifier = Modifier.weight(1f), - colors = FilterChipDefaults.filterChipColors( - selectedContainerColor = MaterialTheme.colorScheme.primary, - selectedLabelColor = MaterialTheme.colorScheme.onPrimary, - containerColor = MaterialTheme.colorScheme.surfaceContainerLow, - labelColor = MaterialTheme.colorScheme.onSurface, - ), - border = FilterChipDefaults.filterChipBorder( - enabled = true, - selected = localWeightUnit == WeightUnit.KG, - borderColor = MaterialTheme.colorScheme.outline, - selectedBorderColor = MaterialTheme.colorScheme.primary, - ), - ) - FilterChip( - selected = localWeightUnit == WeightUnit.LB, - onClick = { - val changed = localWeightUnit != WeightUnit.LB - localWeightUnit = WeightUnit.LB - onWeightUnitChange(WeightUnit.LB) - // Reset increment to default when unit changes (options differ per system) - if (changed) onWeightIncrementChange(-1f) - }, - label = { Text(stringResource(Res.string.label_lbs)) }, - modifier = Modifier.weight(1f), - colors = FilterChipDefaults.filterChipColors( - selectedContainerColor = MaterialTheme.colorScheme.primary, - selectedLabelColor = MaterialTheme.colorScheme.onPrimary, - containerColor = MaterialTheme.colorScheme.surfaceContainerLow, - labelColor = MaterialTheme.colorScheme.onSurface, - ), - border = FilterChipDefaults.filterChipBorder( - enabled = true, - selected = localWeightUnit == WeightUnit.LB, - borderColor = MaterialTheme.colorScheme.outline, - selectedBorderColor = MaterialTheme.colorScheme.primary, - ), - ) - } - - // Issue #266: Weight Increment Picker - Spacer(modifier = Modifier.height(Spacing.small)) - HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)) - Spacer(modifier = Modifier.height(Spacing.small)) - - val incrementOptions = if (localWeightUnit == WeightUnit.KG) { - Constants.WEIGHT_INCREMENT_OPTIONS_KG - } else { - Constants.WEIGHT_INCREMENT_OPTIONS_LB - } - val effectiveIncrement = if (weightIncrement > 0f) { - weightIncrement - } else if (localWeightUnit == WeightUnit.KG) { - Constants.DEFAULT_WEIGHT_INCREMENT_KG - } else { - Constants.DEFAULT_WEIGHT_INCREMENT_LB - } - val unitLabel = if (localWeightUnit == WeightUnit.KG) "kg" else "lb" - - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { showWeightIncrementDialog = true } - .padding(vertical = Spacing.small), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column { - Text( - text = "Weight Increment", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = "${UnitConverter.formatDecimal(effectiveIncrement)} $unitLabel per step", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Icon( - imageVector = Icons.Default.ChevronRight, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - // Weight increment selection dialog - if (showWeightIncrementDialog) { - AlertDialog( - onDismissRequest = { showWeightIncrementDialog = false }, - title = { Text("Weight Increment") }, - text = { - Column( - verticalArrangement = Arrangement.spacedBy(Spacing.extraSmall), - ) { - Text( - text = "Choose how much weight changes with each +/- tap", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(modifier = Modifier.height(Spacing.small)) - incrementOptions.forEach { option -> - val isSelected = kotlin.math.abs(effectiveIncrement - option) < 0.001f - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - onWeightIncrementChange(option) - showWeightIncrementDialog = false - } - .padding(vertical = Spacing.small), - verticalAlignment = Alignment.CenterVertically, - ) { - RadioButton( - selected = isSelected, - onClick = { - onWeightIncrementChange(option) - showWeightIncrementDialog = false - }, - ) - Spacer(modifier = Modifier.width(Spacing.small)) - Text( - text = "${UnitConverter.formatDecimal(option)} $unitLabel", - style = MaterialTheme.typography.bodyLarge, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, - color = if (isSelected) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurface - }, - ) - } - } - } - }, - confirmButton = { - TextButton(onClick = { showWeightIncrementDialog = false }) { - Text("Cancel") - } - }, - ) - } - - // Issue #229: Body Weight Input - Spacer(modifier = Modifier.height(Spacing.small)) - HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)) - Spacer(modifier = Modifier.height(Spacing.small)) - - val bodyWeightUnitLabel = if (localWeightUnit == WeightUnit.KG) "kg" else "lb" - val displayBodyWeight = if (bodyWeightKg > 0f) { - if (localWeightUnit == WeightUnit.KG) { - "${UnitConverter.formatDecimal(bodyWeightKg)} $bodyWeightUnitLabel" - } else { - "${UnitConverter.formatDecimal(UnitConverter.kgToLb(bodyWeightKg))} $bodyWeightUnitLabel" - } - } else { - "Not set" - } - val bodyWeightDescription = latestMatchingHealthBodyWeight?.let { measurement -> - "$displayBodyWeight - synced from ${measurement.provider.displayName}" - } ?: "$displayBodyWeight — for bodyweight exercise volume" - - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { showBodyWeightDialog = true } - .padding(vertical = Spacing.small), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column { - Text( - text = "Body Weight", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = bodyWeightDescription, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Icon( - imageVector = Icons.Default.ChevronRight, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - Spacer(modifier = Modifier.height(Spacing.small)) - HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)) - Spacer(modifier = Modifier.height(Spacing.small)) - - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onNavigateToEquipmentRack() } - .padding(vertical = Spacing.small), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Row( - modifier = Modifier.weight(1f), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Default.FitnessCenter, - contentDescription = stringResource(Res.string.cd_equipment_rack), - tint = MaterialTheme.colorScheme.primary, - ) - Spacer(modifier = Modifier.width(Spacing.small)) - Column { - Text( - text = stringResource(Res.string.equipment_rack_title), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = stringResource(Res.string.equipment_rack_description), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - Icon( - imageVector = Icons.Default.ChevronRight, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - // Body weight input dialog - if (showBodyWeightDialog) { - AlertDialog( - onDismissRequest = { showBodyWeightDialog = false }, - title = { Text("Body Weight") }, - text = { - Column( - verticalArrangement = Arrangement.spacedBy(Spacing.small), - ) { - Text( - text = "Used to estimate volume for bodyweight exercises (push-ups, pull-ups, etc.)", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - ConfirmEditTextField( - value = bodyWeightInput, - onValueChange = { input -> - // Allow only valid numeric input - if (input.isEmpty() || input.matches(Regex("^\\d{0,3}(\\.\\d{0,1})?$"))) { - bodyWeightInput = input - } - }, - label = { Text("Weight ($bodyWeightUnitLabel)") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - val minDisplay = if (localWeightUnit == WeightUnit.KG) "20" else "44" - val maxDisplay = if (localWeightUnit == WeightUnit.KG) "300" else "660" - Text( - text = "Range: $minDisplay–$maxDisplay $bodyWeightUnitLabel", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - }, - confirmButton = { - TextButton( - onClick = { - val parsed = bodyWeightInput.toFloatOrNull() - if (parsed != null) { - val inKg = if (localWeightUnit == WeightUnit.LB) { - UnitConverter.lbToKg(parsed) - } else { - parsed - } - // Clamp to valid range: 20-300 kg - val clamped = inKg.coerceIn(20f, 300f) - onBodyWeightKgChange(clamped) - } - showBodyWeightDialog = false - }, - ) { - Text("Save") - } - }, - dismissButton = { - TextButton(onClick = { showBodyWeightDialog = false }) { - Text("Cancel") - } - }, - ) - } - } - } - // Appearance Section - Material 3 Expressive Card( modifier = Modifier @@ -1260,7 +729,21 @@ fun SettingsTab( } } - // Workout Preferences Section - Material 3 Expressive + GlobalSettingsSectionCard( + title = stringResource(Res.string.settings_video_behavior), + icon = Icons.Default.Tune, + ) { + GlobalSettingsSwitchRow( + title = stringResource(Res.string.settings_show_exercise_videos), + description = stringResource( + Res.string.settings_show_exercise_videos_description, + ), + checked = enableVideoPlayback, + onCheckedChange = onEnableVideoPlaybackChange, + ) + } + + // Data Management Section - Material 3 Expressive Card( modifier = Modifier .fillMaxWidth() @@ -1268,7 +751,7 @@ fun SettingsTab( colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHighest), // Material 3 Expressive: Higher contrast shape = MaterialTheme.shapes.medium, // Material 3 Expressive: More rounded (was 16dp) elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), // Material 3 Expressive: Higher elevation (was 4dp) - border = BorderStroke(2.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)), // Material 3 Expressive: Thicker border (was 1dp) + border = BorderStroke(2.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.2f)), // Material 3 Expressive: Thicker border, error color for destructive action ) { Column( modifier = Modifier @@ -1282,22 +765,22 @@ fun SettingsTab( .shadow(8.dp, MaterialTheme.shapes.medium) // Material 3 Expressive: More shadow, more rounded (was 16dp) .background( Brush.linearGradient( - colors = listOf(Color(0xFF6366F1), Color(0xFF8B5CF6)), + colors = listOf(Color(0xFFF97316), Color(0xFFEF4444)), ), MaterialTheme.shapes.medium, // Material 3 Expressive: More rounded (was 16dp) ), contentAlignment = Alignment.Center, ) { Icon( - Icons.Default.Tune, - contentDescription = stringResource(Res.string.cd_advanced_settings), + Icons.Default.DeleteForever, + contentDescription = stringResource(Res.string.cd_delete_workouts), tint = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.size(24.dp), // Material 3 Expressive: Larger icon ) } Spacer(modifier = Modifier.width(Spacing.medium)) Text( - "Workout Preferences", + "Data Management", style = MaterialTheme.typography.titleLarge, // Material 3 Expressive: Larger (was titleMedium) fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface, @@ -1305,1159 +788,69 @@ fun SettingsTab( } Spacer(modifier = Modifier.height(Spacing.small)) - // Issue #167: Summary Countdown now controls autoplay behavior - // - Off (-1): Skip summary, auto-advance immediately - // - Unlimited (0): Show summary, wait for manual tap (like old autoplay OFF) - // - 5-30s: Show summary, auto-advance after countdown (like old autoplay ON) + // Auto-backup toggle (Phase 36) Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f)) { Text( - text = "Set Summary", - style = MaterialTheme.typography.titleMedium, + "Auto-Backup Workouts & Routines", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface, ) + Spacer(modifier = Modifier.height(4.dp)) Text( - text = "Off = skip summary, Unlimited = manual, 5-30s = auto-advance", + "Automatically save single workouts and completed routines to local backup files", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - - Spacer(modifier = Modifier.width(16.dp)) - - CountdownDropdown( - label = "", - selectedValue = summaryCountdownSeconds, - options = listOf(-1, 0, 5, 10, 15, 20, 25, 30), - onValueSelected = { onSummaryCountdownChange(it) }, - modifier = Modifier.width(120.dp), - formatLabel = { - when (it) { - -1 -> "Off" - - // Skip summary entirely - 0 -> "Unlimited" - - // Show summary, no auto-advance - else -> "${it}s" - } - }, + Switch( + checked = autoBackupEnabled, + onCheckedChange = onAutoBackupEnabledChange, ) } - Spacer(modifier = Modifier.height(16.dp)) - - // Autostart Countdown - always visible + // Backup Location selector (Phase 42) + var showLocationPicker by remember { mutableStateOf(false) } + Spacer(modifier = Modifier.height(Spacing.small)) Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.surfaceContainerLow, + MaterialTheme.shapes.small, + ) + .padding(horizontal = Spacing.medium, vertical = Spacing.small), verticalAlignment = Alignment.CenterVertically, ) { + Icon( + Icons.Default.FolderOpen, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(Spacing.small)) Column(modifier = Modifier.weight(1f)) { Text( - text = "Autostart Countdown", - style = MaterialTheme.typography.titleMedium, + "Backup Location", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface, ) Text( - text = "Just Lift countdown when handles are grabbed", + when (backupDestination) { + is BackupDestination.Default -> "Default (Downloads/PhoenixBackups)" + is BackupDestination.Custom -> backupDestination.displayName + }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - - Spacer(modifier = Modifier.width(16.dp)) - - CountdownDropdown( - label = "", - selectedValue = autoStartCountdownSeconds, - options = (2..10).toList(), - onValueSelected = { onAutoStartCountdownChange(it) }, - modifier = Modifier.width(100.dp), - ) } - - Spacer(modifier = Modifier.height(Spacing.medium)) - - // Issue #190: Auto-start routine toggle - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - "Auto-start Routine", - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface, - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - "Skip overview and start first exercise immediately", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Switch( - checked = autoStartRoutine, - onCheckedChange = onAutoStartRoutineChange, - ) - } - - Spacer(modifier = Modifier.height(Spacing.medium)) - - // Issue #424: Suggest-only next-set weight recommendations - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - stringResource(Res.string.settings_weight_suggestions_title), - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface, - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - stringResource(Res.string.settings_weight_suggestions_description), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Switch( - checked = weightSuggestionsEnabled, - onCheckedChange = onWeightSuggestionsEnabledChange, - ) - } - - Spacer(modifier = Modifier.height(Spacing.medium)) - - // Issue #517: Default scaling basis for % of 1RM / VBT weight scaling - Column(modifier = Modifier.fillMaxWidth()) { - Text( - "Default Weight Scaling Basis", - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface, - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - "Baseline used when scaling routine weights by percentage", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(modifier = Modifier.height(Spacing.small)) - val scalingBasisOptions = listOf( - ScalingBasis.MAX_WEIGHT_PR to "Max-weight PR", - ScalingBasis.MAX_VOLUME_PR to "Max-volume PR", - ScalingBasis.ESTIMATED_1RM to "Est. 1RM", - ) - SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { - scalingBasisOptions.forEachIndexed { index, (basis, label) -> - SegmentedButton( - shape = SegmentedButtonDefaults.itemShape(index = index, count = scalingBasisOptions.size), - onClick = { onDefaultScalingBasisChange(basis) }, - selected = defaultScalingBasis == basis, - ) { - Text(label, maxLines = 1) - } - } - } - } - - Spacer(modifier = Modifier.height(Spacing.medium)) - - // Issue #595: Opt-in starting weights for newly added routine exercises. - Column(modifier = Modifier.fillMaxWidth()) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - "Routine starting weights", - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface, - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - if (defaultRoutineExerciseUsePercentOfPR) { - "When a baseline exists, newly added routine exercises start at your chosen percentage. You can still edit each exercise before saving." - } else { - "New routine exercises start from the normal manual weight." - }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Switch( - checked = defaultRoutineExerciseUsePercentOfPR, - onCheckedChange = onDefaultRoutineExerciseUsePercentOfPRChange, - ) - } - - if (defaultRoutineExerciseUsePercentOfPR) { - Spacer(modifier = Modifier.height(Spacing.small)) - val routineDefaultPercent = defaultRoutineExerciseWeightPercentOfPR.coerceIn(50, 120) - Text( - "$routineDefaultPercent% of selected baseline", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, - ) - Spacer(modifier = Modifier.height(Spacing.small)) - ExpressiveSlider( - value = routineDefaultPercent.toFloat(), - onValueChange = { value -> - val roundedToFive = (value / 5f).roundToInt() * 5 - onDefaultRoutineExerciseWeightPercentOfPRChange(roundedToFive.coerceIn(50, 120)) - }, - valueRange = 50f..120f, - steps = 13, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(modifier = Modifier.height(Spacing.small)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(Spacing.small), - ) { - listOf(70, 80, 90, 100).forEach { percent -> - FilterChip( - selected = routineDefaultPercent == percent, - onClick = { onDefaultRoutineExerciseWeightPercentOfPRChange(percent) }, - label = { Text("$percent%") }, - modifier = Modifier.weight(1f), - ) - } - } - } - } - - Spacer(modifier = Modifier.height(Spacing.medium)) - - // Enable Video Playback toggle - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column( - modifier = Modifier.weight(1f), - ) { - Text( - "Show Exercise Videos", - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface, - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - "Display exercise demonstration videos (disable to avoid slow loading)", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Switch( - checked = enableVideoPlayback, - onCheckedChange = onEnableVideoPlaybackChange, - ) - } - - Spacer(modifier = Modifier.height(Spacing.medium)) - - // Audio Rep Counter toggle - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column( - modifier = Modifier.weight(1f), - ) { - Text( - "Audio Rep Counter", - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface, - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - "Play spoken rep numbers during working sets", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Switch( - checked = audioRepCountEnabled, - onCheckedChange = onAudioRepCountChange, - ) - } - - Spacer(modifier = Modifier.height(Spacing.medium)) - - // Issue #100: Countdown beeps toggle - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column( - modifier = Modifier.weight(1f), - ) { - Text( - "Countdown Beeps", - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface, - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - "Beep during the last 10 seconds of rest timers and timed sets", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Switch( - checked = countdownBeepsEnabled, - onCheckedChange = onCountdownBeepsChange, - ) - } - - Spacer(modifier = Modifier.height(Spacing.medium)) - - // Issue #100: Rep completion sound toggle - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column( - modifier = Modifier.weight(1f), - ) { - Text( - "Rep Completion Sound", - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface, - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - "Play sound when a rep is completed", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Switch( - checked = repSoundEnabled, - onCheckedChange = onRepSoundChange, - ) - } - - Spacer(modifier = Modifier.height(Spacing.medium)) - - // Issue #237: Motion-triggered set start toggle - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column( - modifier = Modifier.weight(1f), - ) { - Text( - "Motion-Triggered Set Start", - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface, - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - "Start sets by holding the cable instead of countdown", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Switch( - checked = motionStartEnabled, - onCheckedChange = onMotionStartChange, - ) - } - - Spacer(modifier = Modifier.height(Spacing.medium)) - - // Gamification toggle - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column( - modifier = Modifier.weight(1f), - ) { - Text( - "Gamification", - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface, - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - "Show PR celebrations and award badges after workouts", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Switch( - checked = gamificationEnabled, - onCheckedChange = onGamificationEnabledChange, - ) - } - - // Voice Emergency Stop - consolidated from standalone section - Spacer(modifier = Modifier.height(Spacing.small)) - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) - Spacer(modifier = Modifier.height(Spacing.medium)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column( - modifier = Modifier.weight(1f), - ) { - Text( - stringResource(Res.string.settings_voice_stop_title), - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface, - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - stringResource(Res.string.settings_voice_stop_description), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Switch( - checked = voiceStopEnabled, - onCheckedChange = onVoiceStopEnabledChange, - ) - } - - // Safe word configuration (shown when voice stop is enabled) - if (voiceStopEnabled) { - Spacer(modifier = Modifier.height(Spacing.medium)) - - ConfirmEditTextField( - value = localSafeWord, - onValueChange = { newValue -> - localSafeWord = newValue.uppercase().trim() - }, - label = { Text(stringResource(Res.string.settings_safe_word_label)) }, - placeholder = { Text(stringResource(Res.string.settings_safe_word_hint)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.small, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - ) - - Spacer(modifier = Modifier.height(Spacing.small)) - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - if (safeWordCalibrated && safeWord == localSafeWord) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - Icons.Default.CheckCircle, - contentDescription = stringResource(Res.string.cd_calibration_check), - tint = Color(0xFF10B981), - modifier = Modifier.size(20.dp), - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - stringResource(Res.string.settings_calibrated_badge), - style = MaterialTheme.typography.bodyMedium, - color = Color(0xFF10B981), - fontWeight = FontWeight.Bold, - ) - } - } else { - Text( - stringResource(Res.string.settings_calibrate_first), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - modifier = Modifier.weight(1f), - ) - } - - Spacer(modifier = Modifier.width(Spacing.small)) - - Button( - onClick = { - if (localSafeWord.isNotBlank()) { - onSafeWordCalibratedChange(false) - onSafeWordChange(localSafeWord) - showCalibrationDialog = true - } - }, - enabled = localSafeWord.isNotBlank(), - shape = MaterialTheme.shapes.small, - ) { - Text(stringResource(Res.string.settings_calibrate_button)) - } - } - } - } - } - - // Calibration dialog for voice emergency stop - if (showCalibrationDialog && localSafeWord.isNotBlank()) { - SafeWordCalibrationDialog( - safeWord = localSafeWord, - onCalibrated = { - onSafeWordChange(localSafeWord) - onSafeWordCalibratedChange(true) - showCalibrationDialog = false - }, - onDismiss = { - showCalibrationDialog = false - }, - ) - } - - // Color Scheme Section - Compact with visual previews - Card( - modifier = Modifier - .fillMaxWidth() - .shadow(8.dp, MaterialTheme.shapes.medium), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHighest), - shape = MaterialTheme.shapes.medium, - elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), - border = BorderStroke(2.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(Spacing.medium), - ) { - // Easter egg: tap the header 7 times rapidly to unlock disco mode - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.clickable { - val currentTime = KmpUtils.currentTimeMillis() - // Reset if more than 2 seconds since last tap - if (currentTime - lastTapTime > 2000L) { - easterEggTapCount = 1 - } else { - easterEggTapCount++ - } - lastTapTime = currentTime - - // Unlock disco mode after 7 rapid taps - if (easterEggTapCount >= 7 && !discoModeUnlocked) { - showDiscoUnlockDialog = true - onPlayDiscoSound() - onDiscoModeUnlocked() - easterEggTapCount = 0 - } - }, - ) { - Box( - modifier = Modifier - .size(48.dp) - .shadow(8.dp, MaterialTheme.shapes.medium) - .background( - Brush.linearGradient( - colors = if (discoModeActive) { - // Rainbow gradient when disco mode is active - listOf( - Color(0xFFFF0000), - Color(0xFFFF7F00), - Color(0xFFFFFF00), - Color(0xFF00FF00), - Color(0xFF0000FF), - Color(0xFF8B00FF), - ) - } else { - listOf(Color(0xFF3B82F6), Color(0xFF6366F1)) - }, - ), - MaterialTheme.shapes.medium, - ), - contentAlignment = Alignment.Center, - ) { - Icon( - Icons.Default.ColorLens, - contentDescription = stringResource(Res.string.cd_led_scheme), - tint = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier.size(24.dp), - ) - } - Spacer(modifier = Modifier.width(Spacing.medium)) - Text( - "LED Color Scheme", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface, - ) - } - - Spacer(modifier = Modifier.height(Spacing.medium)) - - // Color scheme picker — row of tappable color circles - val colorSchemes = ColorSchemes.ALL - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically, - ) { - colorSchemes.forEachIndexed { index, scheme -> - val isSelected = index == selectedColorSchemeIndex - val isNone = scheme.name == "None" - - // Selection ring + circle - Box( - modifier = Modifier - .size(40.dp) - .then( - if (isSelected) { - Modifier.border( - width = 2.dp, - color = MaterialTheme.colorScheme.primary, - shape = CircleShape, - ) - } else { - Modifier - }, - ) - .padding(3.dp) - .clip(CircleShape) - .then( - if (isNone) { - Modifier.background(Color.DarkGray, CircleShape) - } else { - Modifier.background( - Brush.radialGradient( - scheme.colors.map { Color(it.r, it.g, it.b) }, - ), - CircleShape, - ) - }, - ) - .clickable { onColorSchemeChange(index) }, - contentAlignment = Alignment.Center, - ) { - if (isNone) { - Icon( - imageVector = Icons.Default.PowerSettingsNew, - contentDescription = stringResource(Res.string.cd_leds_off), - tint = Color.Gray, - modifier = Modifier.size(18.dp), - ) - } - } - } - } - - // Selected scheme name - val currentScheme = colorSchemes.getOrElse(selectedColorSchemeIndex) { colorSchemes.first() } - Text( - text = currentScheme.name, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = Spacing.small), - ) - - // Disco mode toggle (only visible when unlocked) - if (discoModeUnlocked) { - Spacer(modifier = Modifier.height(Spacing.medium)) - HorizontalDivider( - modifier = Modifier.padding(vertical = Spacing.small), - color = MaterialTheme.colorScheme.outlineVariant, - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - "🕺", - style = MaterialTheme.typography.titleMedium, - ) - Spacer(modifier = Modifier.width(Spacing.small)) - Column { - Text( - "Disco Mode", - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - if (!isConnected) { - "Connect to enable" - } else if (discoModeActive) { - "Party time!" - } else { - "Cycle through colors" - }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - Switch( - checked = discoModeActive, - onCheckedChange = { onDiscoModeToggle(it) }, - enabled = isConnected, - ) - } - } - } - } - - // Issue #313: Velocity-Based Training Section - Card( - modifier = Modifier - .fillMaxWidth() - .shadow(8.dp, MaterialTheme.shapes.medium), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHighest), - shape = MaterialTheme.shapes.medium, - elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), - border = BorderStroke(2.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(Spacing.medium), - ) { - Row( - modifier = Modifier.clickable( - // Issue #611: 7-tap easter egg counter gated to only count - // when vulgar mode is on. The clickable is a no-op otherwise, - // so the Dominatrix feature stays unreachable from a clean install. - enabled = verbalEncouragementEnabled && vulgarModeEnabled, - ) { - val currentTime = KmpUtils.currentTimeMillis() - // Reset if more than 2 seconds since last tap - if (currentTime - lastDominatrixTapTime > 2000L) { - dominatrixEasterEggTapCount = 1 - } else { - dominatrixEasterEggTapCount++ - } - lastDominatrixTapTime = currentTime - - // Unlock Dominatrix Mode after 7 rapid taps - if (dominatrixEasterEggTapCount >= 7 && !dominatrixModeUnlocked) { - showDominatrixUnlockDialog = true - onPlayDominatrixUnlockSound() - onDominatrixModeUnlockedChange(true) - dominatrixEasterEggTapCount = 0 - } - }, - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = Modifier - .size(48.dp) - .shadow(8.dp, MaterialTheme.shapes.medium) - .background( - Brush.linearGradient( - colors = listOf(Color(0xFF10B981), Color(0xFF059669)), - ), - MaterialTheme.shapes.medium, - ), - contentAlignment = Alignment.Center, - ) { - Icon( - Icons.Default.Speed, - contentDescription = "Velocity-Based Training", - tint = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier.size(24.dp), - ) - } - Spacer(modifier = Modifier.width(Spacing.medium)) - Text( - "Velocity-Based Training", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface, - ) - } - - // Issue #611: 7-tap hint pill (visible when master + vulgar on, dominatrix locked) - if ( - AdultModePresentation.shouldShowDominatrixHint( - verbalEncouragementEnabled = verbalEncouragementEnabled, - vulgarModeEnabled = vulgarModeEnabled, - dominatrixModeUnlocked = dominatrixModeUnlocked, - ) - ) { - Spacer(modifier = Modifier.height(Spacing.small)) - Surface( - shape = MaterialTheme.shapes.small, - color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f), - modifier = Modifier.fillMaxWidth(), - ) { - Text( - stringResource(Res.string.settings_dominatrix_unlock_hint), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSecondaryContainer, - modifier = Modifier.padding(horizontal = Spacing.medium, vertical = 8.dp), - ) - } - } - Spacer(modifier = Modifier.height(Spacing.small)) - - // Power Loss Threshold slider - Column { - Text( - "Power Loss Threshold", - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface, - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - "Velocity drop percentage that signals fatigue", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(modifier = Modifier.height(Spacing.small)) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - "10%", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - ExpressiveSlider( - value = velocityLossThresholdPercent.toFloat(), - onValueChange = { onVelocityLossThresholdChange(it.roundToInt()) }, - valueRange = 10f..50f, - steps = 7, - modifier = Modifier.weight(1f).padding(horizontal = 8.dp), - ) - Text( - "50%", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Text( - "Current: $velocityLossThresholdPercent%", - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.primary, - ) - } - Text( - "Changes take effect on next workout", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Spacer(modifier = Modifier.height(Spacing.medium)) - - // Auto-end toggle - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - "Auto-End on Velocity Loss", - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface, - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - "Automatically end set when threshold is reached", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Switch( - checked = autoEndOnVelocityLoss, - onCheckedChange = onAutoEndOnVelocityLossChange, - ) - } - - Spacer(modifier = Modifier.height(Spacing.medium)) - HorizontalDivider( - modifier = Modifier.padding(vertical = Spacing.small), - color = MaterialTheme.colorScheme.outlineVariant, - ) - - // ===== Issue #611: Verbal Encouragement (master toggle) ===== - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - stringResource(Res.string.settings_verbal_encouragement_title), - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface, - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - stringResource(Res.string.settings_verbal_encouragement_description), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Switch( - checked = verbalEncouragementEnabled, - onCheckedChange = { checked -> - // Master-off cascades vulgar + dominatrix off via the preferences setter - // (see SettingsPreferencesManager.setVerbalEncouragementEnabled). - // When the user is enabling the master for the first time, the - // existing audio cues master switch (beepsEnabled) gates everything. - onVerbalEncouragementEnabledChange(checked) - }, - ) - } - - // ===== Issue #611: Vulgar Mode sub-row (visible iff master on) ===== - if (verbalEncouragementEnabled) { - Spacer(modifier = Modifier.height(Spacing.small)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - stringResource(Res.string.settings_vulgar_mode_title), - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface, - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - stringResource(Res.string.settings_vulgar_mode_description), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - stringResource(Res.string.settings_vulgar_mode_headphone_warning), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Switch( - checked = vulgarModeEnabled, - onCheckedChange = { checked -> - // Issue #611 (PR-followup #613): gate on the one-shot - // decline-remember flag, not the adultsOnlyConfirmed - // confirm flag. confirmed=false (decline path) must NOT - // re-trigger the modal — see VerbalEncouragementPreferenceCascadeTest. - if (checked && !adultsOnlyPrompted) { - // Show the 18+ modal flow. Confirm flips - // adultsOnlyConfirmed + marks prompted; decline only - // marks prompted so the modal never re-appears. - showAdultsOnlyDialog = true - } else { - onVulgarModeEnabledChange(checked) - } - }, - ) - } - - // ===== Issue #611: Tier chip selector (MILD / STRONG / MIX) ===== - if (vulgarModeEnabled) { - Spacer(modifier = Modifier.height(Spacing.small)) - Text( - stringResource(Res.string.settings_vulgar_mode_tier_label), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface, - ) - Spacer(modifier = Modifier.height(4.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(Spacing.small), - ) { - VulgarTier.entries.forEach { tier -> - FilterChip( - selected = vulgarTier == tier, - onClick = { onVulgarTierChange(tier) }, - label = { - Text( - stringResource( - when (tier) { - VulgarTier.MILD -> Res.string.settings_vulgar_mode_tier_mild - VulgarTier.STRONG -> Res.string.settings_vulgar_mode_tier_strong - VulgarTier.MIX -> Res.string.settings_vulgar_mode_tier_mix - }, - ), - ) - }, - ) - } - } - } - - // ===== Issue #611: Dominatrix Mode sub-sub-row ===== - if (vulgarModeEnabled && dominatrixModeUnlocked && adultsOnlyConfirmed) { - Spacer(modifier = Modifier.height(Spacing.small)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - stringResource(Res.string.settings_dominatrix_mode_title), - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface, - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - stringResource(Res.string.settings_dominatrix_mode_description), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Switch( - checked = dominatrixModeActive, - onCheckedChange = onDominatrixModeActiveChange, - ) - } - } - } - } - } - - // Data Management Section - Material 3 Expressive - Card( - modifier = Modifier - .fillMaxWidth() - .shadow(8.dp, MaterialTheme.shapes.medium), // Material 3 Expressive: More shadow, more rounded - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHighest), // Material 3 Expressive: Higher contrast - shape = MaterialTheme.shapes.medium, // Material 3 Expressive: More rounded (was 16dp) - elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), // Material 3 Expressive: Higher elevation (was 4dp) - border = BorderStroke(2.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.2f)), // Material 3 Expressive: Thicker border, error color for destructive action - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(Spacing.medium), - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Box( - modifier = Modifier - .size(48.dp) // Material 3 Expressive: Larger (was 40dp) - .shadow(8.dp, MaterialTheme.shapes.medium) // Material 3 Expressive: More shadow, more rounded (was 16dp) - .background( - Brush.linearGradient( - colors = listOf(Color(0xFFF97316), Color(0xFFEF4444)), - ), - MaterialTheme.shapes.medium, // Material 3 Expressive: More rounded (was 16dp) - ), - contentAlignment = Alignment.Center, - ) { - Icon( - Icons.Default.DeleteForever, - contentDescription = stringResource(Res.string.cd_delete_workouts), - tint = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier.size(24.dp), // Material 3 Expressive: Larger icon - ) - } - Spacer(modifier = Modifier.width(Spacing.medium)) - Text( - "Data Management", - style = MaterialTheme.typography.titleLarge, // Material 3 Expressive: Larger (was titleMedium) - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface, - ) - } - Spacer(modifier = Modifier.height(Spacing.small)) - - // Auto-backup toggle (Phase 36) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - "Auto-Backup Workouts & Routines", - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface, - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - "Automatically save single workouts and completed routines to local backup files", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Switch( - checked = autoBackupEnabled, - onCheckedChange = onAutoBackupEnabledChange, - ) - } - - // Backup Location selector (Phase 42) - var showLocationPicker by remember { mutableStateOf(false) } - Spacer(modifier = Modifier.height(Spacing.small)) - Row( - modifier = Modifier - .fillMaxWidth() - .background( - MaterialTheme.colorScheme.surfaceContainerLow, - MaterialTheme.shapes.small, - ) - .padding(horizontal = Spacing.medium, vertical = Spacing.small), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - Icons.Default.FolderOpen, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(Spacing.small)) - Column(modifier = Modifier.weight(1f)) { - Text( - "Backup Location", - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - when (backupDestination) { - is BackupDestination.Default -> "Default (Downloads/PhoenixBackups)" - is BackupDestination.Custom -> backupDestination.displayName - }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - Spacer(modifier = Modifier.height(4.dp)) + Spacer(modifier = Modifier.height(4.dp)) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(Spacing.small), @@ -2652,84 +1045,18 @@ fun SettingsTab( } } - // Achievements Section - Material 3 Expressive (hidden when gamification is disabled) - if (gamificationEnabled) { - Card( - modifier = Modifier - .fillMaxWidth() - .shadow(8.dp, MaterialTheme.shapes.medium), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHighest), - shape = MaterialTheme.shapes.medium, - elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), - border = BorderStroke(2.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(Spacing.medium), - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Box( - modifier = Modifier - .size(48.dp) - .shadow(8.dp, MaterialTheme.shapes.medium) - .background( - Brush.linearGradient( - colors = listOf(Color(0xFFFFD700), Color(0xFFFFA500)), - ), - MaterialTheme.shapes.medium, - ), - contentAlignment = Alignment.Center, - ) { - Icon( - Icons.Default.MilitaryTech, - contentDescription = stringResource(Res.string.cd_achievements), - tint = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier.size(24.dp), - ) - } - Spacer(modifier = Modifier.width(Spacing.medium)) - Text( - "Achievements", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface, - ) - } - Spacer(modifier = Modifier.height(Spacing.small)) - - OutlinedButton( - onClick = onNavigateToBadges, - modifier = Modifier - .fillMaxWidth() - .height(56.dp), - shape = MaterialTheme.shapes.extraLarge, - colors = ButtonDefaults.outlinedButtonColors( - contentColor = MaterialTheme.colorScheme.primary, - ), - border = BorderStroke(2.dp, MaterialTheme.colorScheme.primary), - ) { - Icon( - Icons.Default.EmojiEvents, - contentDescription = stringResource(Res.string.cd_view_badges), - modifier = Modifier.size(24.dp), - ) - Spacer(modifier = Modifier.width(Spacing.small)) - Text( - "View Badges & Streaks", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - } - - Spacer(modifier = Modifier.height(4.dp)) - Text( - "Track your progress, earn badges, and maintain your workout streak", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } + // Material 3 Expressive: Delete All dialog + if (showDeleteAllDialog) { + DestructiveConfirmDialog( + title = stringResource(Res.string.delete_all_workouts_title), + message = stringResource(Res.string.delete_all_workouts_message), + confirmText = stringResource(Res.string.delete_all), + onConfirm = { + onDeleteAllWorkouts() + showDeleteAllDialog = false + }, + onDismiss = { showDeleteAllDialog = false }, + ) } // Developer Tools Section - Material 3 Expressive @@ -2974,23 +1301,9 @@ fun SettingsTab( "Open source community project to control Vitruvian Trainer machines locally.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - - // Material 3 Expressive: Delete All dialog - if (showDeleteAllDialog) { - DestructiveConfirmDialog( - title = stringResource(Res.string.delete_all_workouts_title), - message = stringResource(Res.string.delete_all_workouts_message), - confirmText = stringResource(Res.string.delete_all), - onConfirm = { - onDeleteAllWorkouts() - showDeleteAllDialog = false - }, - onDismiss = { showDeleteAllDialog = false }, - ) + ) + } + } } // Connection error dialog (ConnectingOverlay removed - status shown in top bar button) @@ -3001,47 +1314,6 @@ fun SettingsTab( ) } - // Disco Mode Unlock Celebration Dialog - if (showDiscoUnlockDialog) { - DiscoModeUnlockDialog( - onDismiss = { showDiscoUnlockDialog = false }, - ) - } - - // Issue #611: Dominatrix Mode Unlock Celebration Dialog - if (showDominatrixUnlockDialog) { - DominatrixUnlockDialog( - onDismiss = { showDominatrixUnlockDialog = false }, - ) - } - - // Issue #611: 18+ Adults Only confirmation modal (fires once per install on first vulgar-on) - if (showAdultsOnlyDialog) { - AdultsOnlyConfirmDialog( - onConfirm = { - showAdultsOnlyDialog = false - // Confirm: write both confirmed (cascade-enable path) and prompted - // (one-shot gate). Mirrors SettingsPreferencesManager.setAdultsOnlyConfirmed. - onAdultsOnlyConfirmedChange(true) - onAdultsOnlyPromptedChange(true) - onVulgarModeEnabledChange(true) - }, - onDecline = { - showAdultsOnlyDialog = false - // Decline: ONLY mark the one-shot prompt gate. The cascade - // invariant now checks prompted (not confirmed), so declining - // does not block future vulgar-on attempts. - onAdultsOnlyPromptedChange(true) - }, - onDismiss = { - showAdultsOnlyDialog = false - // Dismiss (back-press / scrim) without confirmation — user must - // explicitly tap a button to either confirm or decline. Does NOT - // mark prompted so a subsequent vulgar-on toggle can re-prompt. - }, - ) - } - // Backup confirmation dialog if (showBackupDialog) { AlertDialog( @@ -3278,205 +1550,6 @@ fun SettingsTab( } } -/** - * Calibration dialog: user must say the safe word 3 times successfully. - * Uses SafeWordListenerFactory via Koin to create a listener. - */ -@Composable -private fun SafeWordCalibrationDialog( - safeWord: String, - onCalibrated: () -> Unit, - onDismiss: () -> Unit, -) { - val listenerFactory: com.devil.phoenixproject.domain.voice.SafeWordListenerFactory = koinInject() - var detectionCount by remember { mutableStateOf(0) } - var calibrationFailed by remember { mutableStateOf(false) } - var micError by remember { mutableStateOf(false) } - var listener by remember { mutableStateOf(null) } - val scope = rememberCoroutineScope() - - // Create and start listener - DisposableEffect(safeWord) { - val newListener = try { - listenerFactory.create(safeWord) - } catch (_: Exception) { - micError = true - null - } - listener = newListener - newListener?.startListening() - - onDispose { - newListener?.stopListening() - listener = null - } - } - - // Collect detected words - val currentListener = listener - if (currentListener != null) { - val isListening by currentListener.isListening.collectAsState() - - LaunchedEffect(currentListener) { - currentListener.detectedWord.collect { - detectionCount++ - if (detectionCount >= 3) { - currentListener.stopListening() - onCalibrated() - } - } - } - - // Timeout: if not listening after initial start and we haven't completed, mark mic error - LaunchedEffect(isListening) { - if (!isListening && detectionCount == 0) { - // Give a brief moment for the listener to start - kotlinx.coroutines.delay(3000) - val stillNotListening = !currentListener.isListening.value - if (stillNotListening && detectionCount == 0) { - micError = true - } - } - } - } - - AlertDialog( - onDismissRequest = onDismiss, - shape = MaterialTheme.shapes.medium, - title = { - Text( - stringResource(Res.string.settings_calibration_title), - fontWeight = FontWeight.Bold, - ) - }, - text = { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(Spacing.medium), - ) { - when { - micError -> { - Text( - stringResource(Res.string.settings_calibration_mic_error), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error, - ) - OutlinedButton( - onClick = { com.devil.phoenixproject.util.openAppSettings() }, - shape = MaterialTheme.shapes.small, - ) { - Text(stringResource(Res.string.settings_calibration_open_settings)) - } - } - - calibrationFailed -> { - Text( - stringResource(Res.string.settings_calibration_fail), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error, - ) - } - - else -> { - // Prompt - Text( - stringResource(Res.string.settings_calibration_prompt), - style = MaterialTheme.typography.bodyLarge, - ) - Text( - safeWord, - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, - ) - - // 3 circles showing progress - // settings-profile-18: animateColorAsState + scale pop + animated Check icon. - // AnimatedVisibility is not used here (scope ambiguity in Row>Box context); - // scale+alpha modifier achieves identical visual with no overhead. - // reduceMotion: snap colour, scale at 1f always, check appears instantly. - val reduceMotionCalib = LocalPlatformAccessibilitySettings.current.reduceMotion - // Hoisted out of repeat lambda — ForgeGreen is the brand calibration tint; - // SpringBouncyColor is the typed spring preset. - val calibFillColor = ForgeGreen - val calibColorSpec = ExpressiveMotion.SpringBouncyColor - Row( - horizontalArrangement = Arrangement.spacedBy(Spacing.medium), - modifier = Modifier.padding(vertical = Spacing.small), - ) { - repeat(3) { index -> - val filled = index < detectionCount - val circleColor by animateColorAsState( - targetValue = if (filled) calibFillColor - else MaterialTheme.colorScheme.surfaceVariant, - animationSpec = if (reduceMotionCalib) snap() else calibColorSpec, - label = "circleColor$index", - ) - val circleScale by animateFloatAsState( - // reduceMotion: always 1f — no scale change (settled channel) - targetValue = if (filled || reduceMotionCalib) 1.0f else 0.8f, - animationSpec = if (reduceMotionCalib) snap() - else ExpressiveMotion.SpringBouncy, - label = "circleScale$index", - ) - // Check icon animates in with scale+alpha spring (no AnimatedVisibility - // to avoid RowScope extension ambiguity inside nested Box). - val checkScale by animateFloatAsState( - targetValue = if (filled) 1.0f else 0.0f, - animationSpec = if (reduceMotionCalib) snap() - else ExpressiveMotion.SpringBouncy, - label = "checkScale$index", - ) - Box( - modifier = Modifier - .size(40.dp) - .scale(circleScale) - .background( - color = circleColor, - shape = RoundedCornerShape(50), - ), - contentAlignment = Alignment.Center, - ) { - // a11y: null contentDescription when not filled — alpha=0 alone - // does not hide from screen readers; null suppresses the node. - Icon( - Icons.Default.Check, - contentDescription = if (filled) stringResource(Res.string.cd_calibration_check) else null, - tint = Color.White, - modifier = Modifier - .size(24.dp) - .scale(checkScale) - .alpha(checkScale), - ) - } - } - } - - // Progress text - Text( - stringResource(Res.string.settings_calibration_progress, detectionCount), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - // Listening indicator - Text( - stringResource(Res.string.settings_calibration_listening), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, - ) - } - } - } - }, - confirmButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(Res.string.action_cancel)) - } - }, - ) -} private fun formatCount(count: Long): String = when { count >= 1_000_000 -> "${count / 1_000_000}.${(count % 1_000_000) / 100_000}M" @@ -3484,429 +1557,3 @@ private fun formatCount(count: Long): String = when { count >= 1_000 -> "${count / 1_000}.${(count % 1_000) / 100}K" else -> count.toString() } - -@Composable -private fun AdultModeDialogCard( - presentation: AdultModePresentation, - content: @Composable () -> Unit, -) { - Card( - modifier = Modifier - .fillMaxWidth() - .shadow(8.dp, MaterialTheme.shapes.medium), - colors = CardDefaults.cardColors( - containerColor = adultModeContainerColor(presentation.containerTone), - ), - shape = MaterialTheme.shapes.medium, - elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), - border = BorderStroke(2.dp, adultModeBorderColor(presentation)), - ) { - content() - } -} - -@Composable -private fun adultModeContainerColor(tone: AdultModeDialogTone): Color = when (tone) { - AdultModeDialogTone.ThemeSurface -> MaterialTheme.colorScheme.surfaceContainerHighest -} - -@Composable -private fun adultModeBorderColor(presentation: AdultModePresentation): Color = when { - // Keep the policy honest without reintroducing a bespoke palette: even if a - // future policy asks for a risky accent, map it back through theme tokens. - presentation.usesBespokePinkAccent -> MaterialTheme.colorScheme.error.copy(alpha = 0.24f) - presentation.usesBrandAccent -> MaterialTheme.colorScheme.primary.copy(alpha = 0.2f) - else -> MaterialTheme.colorScheme.outlineVariant -} - -@Composable -private fun AdultModeActions( - presentation: AdultModePresentation, - confirmLabel: String, - onConfirm: () -> Unit, - declineLabel: String? = null, - onDecline: (() -> Unit)? = null, -) { - when (presentation.actionLayout) { - AdultModeActionLayout.SinglePrimary -> AdultModeActionButton( - label = confirmLabel, - tone = presentation.confirmTone, - onClick = onConfirm, - modifier = Modifier.fillMaxWidth(), - ) - - AdultModeActionLayout.StackedFullWidth -> Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(Spacing.small), - ) { - AdultModeActionButton( - label = confirmLabel, - tone = presentation.confirmTone, - onClick = onConfirm, - modifier = Modifier.fillMaxWidth(), - ) - if (declineLabel != null && onDecline != null) { - AdultModeActionButton( - label = declineLabel, - tone = presentation.declineTone ?: AdultModeActionTone.Outline, - onClick = onDecline, - modifier = Modifier.fillMaxWidth(), - ) - } - } - } -} - -@Composable -private fun AdultModeActionButton( - label: String, - tone: AdultModeActionTone, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - when (tone) { - AdultModeActionTone.Primary -> Button( - onClick = onClick, - modifier = modifier.heightIn(min = 48.dp), - shape = MaterialTheme.shapes.extraLarge, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary, - ), - ) { - AdultModeActionLabel( - text = label, - color = MaterialTheme.colorScheme.onPrimary, - fontWeight = FontWeight.Bold, - ) - } - - AdultModeActionTone.Outline -> OutlinedButton( - onClick = onClick, - modifier = modifier.heightIn(min = 48.dp), - shape = MaterialTheme.shapes.extraLarge, - border = BorderStroke( - 1.dp, - MaterialTheme.colorScheme.outline.copy(alpha = 0.6f), - ), - ) { - AdultModeActionLabel( - text = label, - color = MaterialTheme.colorScheme.onSurface, - ) - } - } -} - -@Composable -private fun AdultModeActionLabel( - text: String, - color: Color, - fontWeight: FontWeight = FontWeight.Medium, -) { - Text( - text = text, - style = MaterialTheme.typography.labelLarge, - fontWeight = fontWeight, - color = color, - textAlign = androidx.compose.ui.text.style.TextAlign.Center, - ) -} - -/** - * Issue #611: Dominatrix Mode unlock celebration dialog. Uses the same Phoenix - * Material 3 surface/card/button idioms as the rest of Settings instead of a - * bespoke dark + pink palette. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun DominatrixUnlockDialog(onDismiss: () -> Unit) { - LaunchedEffect(Unit) { - kotlinx.coroutines.delay(4000) - onDismiss() - } - - val presentation = AdultModePresentation.dominatrixUnlock() - var isVisible by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { isVisible = true } - - val scale by animateFloatAsState( - targetValue = if (isVisible) 1f else 0f, - animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessMedium, - ), - label = "dominatrix_dialog_scale", - ) - - BasicAlertDialog( - onDismissRequest = onDismiss, - modifier = Modifier.scale(scale), - ) { - AdultModeDialogCard(presentation = presentation) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(Spacing.large), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Surface( - modifier = Modifier.size(72.dp), - shape = CircleShape, - color = MaterialTheme.colorScheme.primaryContainer, - ) { - Box(contentAlignment = Alignment.Center) { - Text( - "👑", - style = MaterialTheme.typography.displayMedium, - ) - } - } - Spacer(modifier = Modifier.height(Spacing.medium)) - Text( - text = stringResource(Res.string.settings_dominatrix_unlock_toast), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface, - textAlign = androidx.compose.ui.text.style.TextAlign.Center, - ) - Spacer(modifier = Modifier.height(Spacing.small)) - Text( - text = stringResource(Res.string.settings_dominatrix_mode_description), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = androidx.compose.ui.text.style.TextAlign.Center, - ) - Spacer(modifier = Modifier.height(Spacing.large)) - - AdultModeActions( - presentation = presentation, - confirmLabel = stringResource(Res.string.action_ok), - onConfirm = onDismiss, - ) - } - } - } -} - -/** - * Issue #611: 18+ Adults Only confirmation modal. Fires once per install when - * the user toggles Vulgar Mode from off to on. Confirm flips - * adultsOnlyConfirmed first then re-issues the vulgar-mode setter; decline - * only persists the one-shot decline-remember flag (`adultsOnlyPrompted`) so - * the modal never re-prompts — see VerbalEncouragementPreferenceCascadeTest - * "decline path leaves modal dormant on subsequent vulgar-on toggles". - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun AdultsOnlyConfirmDialog( - onConfirm: () -> Unit, - onDecline: () -> Unit, - onDismiss: () -> Unit, -) { - val presentation = AdultModePresentation.adultsOnlyConfirmation() - - BasicAlertDialog(onDismissRequest = onDismiss) { - AdultModeDialogCard(presentation = presentation) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(Spacing.large), - ) { - Surface( - shape = MaterialTheme.shapes.small, - color = MaterialTheme.colorScheme.errorContainer, - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = Spacing.medium, vertical = Spacing.small), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - Icons.Default.Warning, - contentDescription = null, - tint = MaterialTheme.colorScheme.onErrorContainer, - modifier = Modifier.size(20.dp), - ) - Spacer(modifier = Modifier.width(Spacing.small)) - Text( - text = stringResource(Res.string.settings_adults_only_title), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onErrorContainer, - ) - } - } - Spacer(modifier = Modifier.height(Spacing.medium)) - Text( - text = stringResource(Res.string.settings_adults_only_body), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - Spacer(modifier = Modifier.height(Spacing.medium)) - Surface( - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.small, - color = MaterialTheme.colorScheme.surfaceContainerHigh, - ) { - Text( - text = stringResource(Res.string.settings_adults_only_compliance_footer), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(Spacing.medium), - ) - } - Spacer(modifier = Modifier.height(Spacing.large)) - - AdultModeActions( - presentation = presentation, - confirmLabel = stringResource(Res.string.settings_adults_only_confirm), - onConfirm = onConfirm, - declineLabel = stringResource(Res.string.settings_adults_only_decline), - onDecline = onDecline, - ) - } - } - } -} - -/** - * Fun animated dialog celebrating disco mode unlock - */ -@Composable -private fun DiscoModeUnlockDialog(onDismiss: () -> Unit) { - // Auto-dismiss after 4 seconds - LaunchedEffect(Unit) { - kotlinx.coroutines.delay(4000) - onDismiss() - } - - // Animate the scale for a fun pop-in effect - var isVisible by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { isVisible = true } - - val scale by animateFloatAsState( - targetValue = if (isVisible) 1f else 0f, - animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessMedium, - ), - label = "dialog_scale", - ) - - // Rotating disco ball effect - rememberInfiniteTransition for variable-refresh compatibility - val reduceMotion = LocalPlatformAccessibilitySettings.current.reduceMotion - val discoTransition = rememberInfiniteTransition(label = "disco") - - // reduceMotion: bake the flag into targetValue so the channel settles at initial (no movement) - val rotation by discoTransition.animateFloat( - initialValue = 0f, - targetValue = if (reduceMotion) 0f else 360f, - animationSpec = infiniteRepeatable( - animation = tween(durationMillis = 1920, easing = LinearEasing), - repeatMode = RepeatMode.Restart, - ), - label = "disco_rotation", - ) - - // 400ms half-cycle matches original +0.02f/frame @ 60fps (0.5 range ÷ 0.02 = 25 frames × 16ms) - val glowAlpha by discoTransition.animateFloat( - initialValue = 0.3f, - targetValue = if (reduceMotion) 0.3f else 0.8f, - animationSpec = infiniteRepeatable( - animation = tween(durationMillis = 400, easing = LinearEasing), - repeatMode = RepeatMode.Reverse, - ), - label = "disco_glow", - ) - - AlertDialog( - onDismissRequest = onDismiss, - modifier = Modifier.scale(scale), - containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, - shape = MaterialTheme.shapes.extraLarge, - title = { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxWidth(), - ) { - // Spinning disco ball emoji with glow - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .size(80.dp) - .rotate(rotation) - .background( - Brush.radialGradient( - colors = listOf( - Color.White.copy(alpha = glowAlpha), - Color.Transparent, - ), - ), - shape = RoundedCornerShape(40.dp), - ), - ) { - Text( - "🪩", - style = MaterialTheme.typography.displayLarge, - ) - } - Spacer(modifier = Modifier.height(16.dp)) - Text( - "DISCO MODE UNLOCKED!", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.ExtraBold, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.background( - Brush.linearGradient( - colors = listOf( - Color(0xFFFF0000), - Color(0xFFFF7F00), - Color(0xFFFFFF00), - Color(0xFF00FF00), - Color(0xFF0000FF), - Color(0xFF8B00FF), - ), - ), - shape = MaterialTheme.shapes.extraSmall, - ).padding(horizontal = 12.dp, vertical = 4.dp), - ) - } - }, - text = { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - "🕺 Time to get funky! 💃", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface, - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - "Toggle Disco Mode in the LED Color Scheme section to make your trainer party!", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), - modifier = Modifier.padding(horizontal = 8.dp), - ) - } - }, - confirmButton = { - TextButton( - onClick = onDismiss, - modifier = Modifier.height(48.dp), - shape = MaterialTheme.shapes.extraLarge, - ) { - Text( - "🎉 Let's Party!", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, - ) - } - }, - ) -} diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutHud.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutHud.kt index 03c0ac14d..3e755d772 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutHud.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutHud.kt @@ -87,6 +87,7 @@ fun WorkoutHud( onPauseExerciseTimer: () -> Unit = {}, onResumeExerciseTimer: () -> Unit = {}, onResetExerciseTimer: () -> Unit = {}, + vbtEnabled: Boolean = true, velocityLossThresholdPercent: Int = 20, rackLoadAdjustment: RackLoadAdjustment = RackLoadAdjustment(), currentWarmupSetIndex: Int = -1, // Issue #646: -1 = no warm-up phase @@ -198,6 +199,7 @@ fun WorkoutHud( formatWeight = formatWeight, isCurrentExerciseBodyweight = isCurrentExerciseBodyweight, latestBiomechanicsResult = latestBiomechanicsResult, + vbtEnabled = vbtEnabled, velocityLossThresholdPercent = velocityLossThresholdPercent, ) } @@ -838,6 +840,7 @@ private fun StatsPage( formatWeight: (Float, WeightUnit) -> String, isCurrentExerciseBodyweight: Boolean = false, latestBiomechanicsResult: BiomechanicsRepResult? = null, + vbtEnabled: Boolean = true, velocityLossThresholdPercent: Int = 20, ) { if (isCurrentExerciseBodyweight) { @@ -905,7 +908,11 @@ private fun StatsPage( if (latestBiomechanicsResult != null) { val mcv = latestBiomechanicsResult.velocity.meanConcentricVelocityMmS val zone = latestBiomechanicsResult.velocity.zone - val zColor = velocityZoneColor(zone) + val zColor = if (vbtEnabled) { + velocityZoneColor(zone) + } else { + MaterialTheme.colorScheme.onSurface + } Card( modifier = Modifier.fillMaxWidth(), @@ -933,11 +940,13 @@ private fun StatsPage( value = "${formatMcv(mcv)} m/s", color = zColor, ) - StatColumn( - label = "Zone", - value = velocityZoneLabel(zone), - color = zColor, - ) + if (vbtEnabled) { + StatColumn( + label = "Zone", + value = velocityZoneLabel(zone), + color = zColor, + ) + } StatColumn( label = "Peak", value = "${formatMcv(latestBiomechanicsResult.velocity.peakVelocityMmS)} m/s", @@ -947,7 +956,7 @@ private fun StatsPage( // Velocity loss (only shown after rep 2) val vloss = latestBiomechanicsResult.velocity.velocityLossPercent - if (vloss != null) { + if (vbtEnabled && vloss != null) { VelocityLossIndicator( currentLossPercent = vloss, thresholdPercent = velocityLossThresholdPercent, diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutTab.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutTab.kt index 7ca369305..6ecd62150 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutTab.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutTab.kt @@ -221,6 +221,7 @@ fun WorkoutTab( onResumeExerciseTimer = actions::onResumeExerciseTimer, onResetExerciseTimer = actions::onResetExerciseTimer, onConfirmBodyweightSetResult = actions::onConfirmBodyweightSetResult, + vbtEnabled = state.vbtEnabled, velocityLossThresholdPercent = state.velocityLossThresholdPercent, weightStepKg = state.weightStepKg, rackLoadAdjustment = state.rackLoadAdjustment, @@ -302,6 +303,7 @@ fun WorkoutTab( onResetExerciseTimer: () -> Unit = {}, onConfirmBodyweightSetResult: (Int, BodyweightVariantOption) -> Unit = { _, _ -> }, // Issue #313: VBT velocity loss threshold for HUD visualization + vbtEnabled: Boolean = true, velocityLossThresholdPercent: Int = 20, // Issue #266/#410: Configurable weight step from user preferences (kg) weightStepKg: Float = 0.25f, @@ -345,6 +347,7 @@ fun WorkoutTab( onPauseExerciseTimer = onPauseExerciseTimer, onResumeExerciseTimer = onResumeExerciseTimer, onResetExerciseTimer = onResetExerciseTimer, + vbtEnabled = vbtEnabled, velocityLossThresholdPercent = velocityLossThresholdPercent, rackLoadAdjustment = rackLoadAdjustment, currentWarmupSetIndex = currentWarmupSetIndex, diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutUiState.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutUiState.kt index 14b2481ad..6a9778cfa 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutUiState.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutUiState.kt @@ -93,6 +93,7 @@ data class WorkoutUiState( // Issue #190: Exercise timer pause state for timed exercise controls val isExerciseTimerPaused: Boolean = false, // Issue #313: VBT velocity loss threshold for HUD visualization + val vbtEnabled: Boolean = true, val velocityLossThresholdPercent: Int = 20, // Issue #266/#410: Configurable weight step from user preferences (kg) val weightStepKg: Float = 0.25f, diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/util/TestTags.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/util/TestTags.kt index 1a6134805..9f3170feb 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/util/TestTags.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/util/TestTags.kt @@ -8,8 +8,9 @@ object TestTags { const val APP_NAV_HOST = "app-nav-host" const val NAV_ANALYTICS = "nav-analytics" - const val NAV_WORKOUTS = "nav-workouts" const val NAV_INSIGHTS = "nav-insights" + const val NAV_HOME = "nav-home" + const val NAV_PROFILE = "nav-profile" const val NAV_SETTINGS = "nav-settings" const val ACTION_CYCLES = "action-cycles" @@ -24,11 +25,15 @@ object TestTags { const val ACTION_START_SET = "action-start-set" const val ACTION_STOP_ROUTINE_OVERVIEW = "action-stop-routine-overview" const val ACTION_STOP_WORKOUT = "action-stop-workout" + const val ACTION_ADD_PROFILE = "action-add-profile" + const val ACTION_EDIT_PROFILE = "action-edit-profile" + const val ACTION_DELETE_PROFILE = "action-delete-profile" const val SCREEN_HOME = "screen-home" const val SCREEN_ANALYTICS = "screen-analytics" const val SCREEN_SMART_INSIGHTS = "screen-smart-insights" const val SCREEN_SETTINGS = "screen-settings" + const val SCREEN_PROFILE = "screen-profile" const val SCREEN_LINK_ACCOUNT = "screen-link-account" const val SCREEN_BADGES = "screen-badges" const val SCREEN_CONNECTION_LOGS = "screen-connection-logs" @@ -51,4 +56,6 @@ object TestTags { const val DIALOG_EXIT_ROUTINE = "dialog-exit-routine" const val DIALOG_STOP_CURRENT_SET = "dialog-stop-current-set" const val DIALOG_EXIT_WORKOUT = "dialog-exit-workout" + + const val PROFILE_SWITCHER_SHEET = "profile-switcher-sheet" } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/AssessmentViewModel.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/AssessmentViewModel.kt index 0684e20cb..598419075 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/AssessmentViewModel.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/AssessmentViewModel.kt @@ -13,9 +13,13 @@ import com.devil.phoenixproject.domain.assessment.LoadVelocityPoint import com.devil.phoenixproject.domain.model.Exercise import com.devil.phoenixproject.domain.model.WorkoutMetric import com.devil.phoenixproject.domain.model.currentTimeMillis +import kotlin.coroutines.cancellation.CancellationException import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.first @@ -56,6 +60,10 @@ sealed class AssessmentStep { data class Complete(val finalOneRepMaxKg: Float, val exerciseName: String) : AssessmentStep() } +sealed interface AssessmentUiEvent { + data object SaveFailed : AssessmentUiEvent +} + /** * ViewModel for the multi-step strength assessment wizard. * @@ -78,6 +86,9 @@ class AssessmentViewModel( private val _exercises = MutableStateFlow>(emptyList()) val exercises: StateFlow> = _exercises.asStateFlow() + private val _events = MutableSharedFlow(extraBufferCapacity = 1) + val events: SharedFlow = _events.asSharedFlow() + private var selectedExercise: Exercise? = null private var assessmentStartTimeMs: Long = 0L private var assessmentResult: AssessmentResult? = null @@ -171,29 +182,11 @@ class AssessmentViewModel( private fun startAssessmentInternal() { assessmentStartTimeMs = currentTimeMillis() - val exercise = selectedExercise ?: return - - // Calculate initial suggested weight. - // exercise.oneRepMaxKg is stored as per-cable in the DB. - // The wizard displays and works with TOTAL weight (what the user sees on the machine). - // Total weight = per-cable * 2. - val existingOneRm = exercise.oneRepMaxKg - val startingLoad = if (existingOneRm != null && existingOneRm > 0f) { - // Start at ~40% of total 1RM - (existingOneRm * 2f) * 0.4f - } else { - 20f // Default starting weight (total) - } - - // Use the engine to get a properly snapped suggestion - val suggestedWeight = assessmentEngine.suggestNextWeight( - currentLoadKg = startingLoad, - currentVelocity = 1.2f, // High velocity assumption for first set - ) + if (selectedExercise == null) return _currentStep.value = AssessmentStep.ProgressiveLoading( currentSetNumber = 1, - suggestedWeightKg = suggestedWeight, + suggestedWeightKg = SAFE_STARTING_LOAD_TOTAL_KG, recordedSets = emptyList(), latestVelocity = null, shouldStop = false, @@ -265,9 +258,11 @@ class AssessmentViewModel( /** * Accept the assessment result and save to database. * + * @param profileId Immutable owner captured by the assessment navigation route * @param overrideKg If non-null and > 0, replaces the estimated 1RM */ - fun acceptResult(overrideKg: Float? = null) { + fun acceptResult(profileId: String, overrideKg: Float? = null) { + require(profileId.isNotBlank()) { "Assessment profileId must not be blank" } val exercise = selectedExercise ?: return val current = _currentStep.value if (current !is AssessmentStep.Results) return @@ -318,6 +313,7 @@ class AssessmentViewModel( totalReps = totalReps, durationMs = durationMs, weightPerCableKg = avgWeight / 2f, + profileId = profileId, ) _currentStep.value = AssessmentStep.Complete( @@ -326,14 +322,20 @@ class AssessmentViewModel( ) Logger.i("Assessment saved: ${exercise.displayName} -> $finalOneRm kg 1RM") + } catch (e: CancellationException) { + throw e } catch (e: Exception) { - Logger.e("Failed to save assessment: ${e.message}") - // Return to results so user can retry + Logger.e(e) { "Failed to save assessment" } _currentStep.value = current + _events.emit(AssessmentUiEvent.SaveFailed) } } } + private companion object { + const val SAFE_STARTING_LOAD_TOTAL_KG = 20f + } + /** * Begin capturing velocity data from BLE metrics during an assessment set. * The user physically performs reps on the machine while we observe the metrics flow. diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/MainViewModel.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/MainViewModel.kt index 642d79dce..1cac0b1f0 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/MainViewModel.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/MainViewModel.kt @@ -37,18 +37,15 @@ import com.devil.phoenixproject.domain.model.RackItemBehavior import com.devil.phoenixproject.domain.model.RackLoadAdjustment import com.devil.phoenixproject.domain.model.RepCount import com.devil.phoenixproject.domain.model.BleCompatibilitySetting -import com.devil.phoenixproject.domain.model.RepCountTiming import com.devil.phoenixproject.domain.model.Routine import com.devil.phoenixproject.domain.model.RoutineExercise import com.devil.phoenixproject.domain.model.RoutineFlowState import com.devil.phoenixproject.domain.model.RoutineLaunchOrigin import com.devil.phoenixproject.presentation.navigation.NavigationRoutes import com.devil.phoenixproject.domain.model.RoutineGroup -import com.devil.phoenixproject.domain.model.ScalingBasis import com.devil.phoenixproject.domain.model.SessionBodyweightState import com.devil.phoenixproject.domain.model.Superset import com.devil.phoenixproject.domain.model.UserPreferences -import com.devil.phoenixproject.domain.model.VulgarTier import com.devil.phoenixproject.domain.model.WeightUnit import com.devil.phoenixproject.domain.model.WorkoutMetric import com.devil.phoenixproject.domain.model.WorkoutParameters @@ -72,6 +69,7 @@ import com.devil.phoenixproject.presentation.manager.JustLiftDefaults import com.devil.phoenixproject.presentation.manager.ResumableProgressInfo import com.devil.phoenixproject.presentation.manager.SettingsManager import com.devil.phoenixproject.presentation.manager.WorkoutServiceController +import com.devil.phoenixproject.util.BackupDestination import com.devil.phoenixproject.util.BackupStats import com.devil.phoenixproject.util.DataBackupManager import kotlinx.coroutines.CancellationException @@ -96,6 +94,22 @@ import kotlinx.coroutines.launch */ data class TopBarAction(val icon: ImageVector, val description: String, val onClick: () -> Unit) +data class SettingsGlobalUiState( + val enableVideoPlayback: Boolean, + val bleCompatibilityMode: BleCompatibilitySetting, + val autoBackupEnabled: Boolean, + val backupDestination: BackupDestination, + val language: String, +) + +private fun UserPreferences.toSettingsGlobalUiState() = SettingsGlobalUiState( + enableVideoPlayback = enableVideoPlayback, + bleCompatibilityMode = bleCompatibilityMode, + autoBackupEnabled = autoBackupEnabled, + backupDestination = backupDestination, + language = language, +) + class MainViewModel constructor( private val bleRepository: BleRepository, private val workoutRepository: WorkoutRepository, @@ -137,7 +151,7 @@ class MainViewModel constructor( ) // === Phase 1b: SettingsManager (extracted from this class) === - val settingsManager = SettingsManager(preferencesManager, bleRepository, viewModelScope) + private val settingsManager = SettingsManager(preferencesManager, userProfileRepository, viewModelScope) // === Phase 1a: HistoryManager (extracted from this class) === val historyManager = HistoryManager(workoutRepository, personalRecordRepository, userProfileRepository, viewModelScope) @@ -274,7 +288,6 @@ class MainViewModel constructor( fun disconnect() = bleConnectionManager.disconnect() fun clearConnectionError() = bleConnectionManager.clearConnectionError() fun dismissConnectionLostAlert() = bleConnectionManager.dismissConnectionLostAlert() - fun cancelAutoConnecting() = bleConnectionManager.cancelAutoConnecting() fun ensureConnection(onConnected: () -> Unit, onFailed: () -> Unit = {}) = bleConnectionManager.ensureConnection(onConnected, onFailed) fun reconnectInterruptedWorkout() { bleConnectionManager.dismissConnectionLostAlert() @@ -315,30 +328,25 @@ class MainViewModel constructor( val enableVideoPlayback: StateFlow get() = settingsManager.enableVideoPlayback val autoplayEnabled: StateFlow get() = settingsManager.autoplayEnabled - fun setWeightUnit(unit: WeightUnit) = settingsManager.setWeightUnit(unit) - fun setStopAtTop(enabled: Boolean) = settingsManager.setStopAtTop(enabled) + val globalSettings: StateFlow = + preferencesManager.preferencesFlow + .map(UserPreferences::toSettingsGlobalUiState) + .stateIn( + viewModelScope, + SharingStarted.Eagerly, + preferencesManager.preferencesFlow.value.toSettingsGlobalUiState(), + ) + fun setEnableVideoPlayback(enabled: Boolean) = settingsManager.setEnableVideoPlayback(enabled) - fun setAudioRepCountEnabled(enabled: Boolean) = settingsManager.setAudioRepCountEnabled(enabled) - fun setRepCountTiming(timing: RepCountTiming) = settingsManager.setRepCountTiming(timing) - fun setSummaryCountdownSeconds(seconds: Int) = settingsManager.setSummaryCountdownSeconds(seconds) - fun setAutoStartCountdownSeconds(seconds: Int) = settingsManager.setAutoStartCountdownSeconds(seconds) - fun setColorScheme(schemeIndex: Int) = settingsManager.setColorScheme(schemeIndex) - fun setWeightIncrement(increment: Float) = settingsManager.setWeightIncrement(increment) - fun setAutoStartRoutine(enabled: Boolean) = settingsManager.setAutoStartRoutine(enabled) - fun setBodyWeightKg(weightKg: Float) = settingsManager.setBodyWeightKg(weightKg) - fun setGamificationEnabled(enabled: Boolean) = settingsManager.setGamificationEnabled(enabled) // Issue #333: BLE small-MTU compatibility path (Auto/On/Off) fun setBleCompatibilityMode(setting: BleCompatibilitySetting) = settingsManager.setBleCompatibilityMode(setting) - fun setCountdownBeepsEnabled(enabled: Boolean) = settingsManager.setCountdownBeepsEnabled(enabled) - fun setRepSoundEnabled(enabled: Boolean) = settingsManager.setRepSoundEnabled(enabled) - fun setMotionStartEnabled(enabled: Boolean) = settingsManager.setMotionStartEnabled(enabled) fun setAutoBackupEnabled(enabled: Boolean) { settingsManager.setAutoBackupEnabled(enabled) refreshBackupStats() } - fun setBackupDestination(destination: com.devil.phoenixproject.util.BackupDestination) { + fun setBackupDestination(destination: BackupDestination) { settingsManager.setBackupDestination(destination) } @@ -346,46 +354,6 @@ class MainViewModel constructor( settingsManager.setLanguage(language) } - // Issue #141: Voice-activated emergency stop - fun setVoiceStopEnabled(enabled: Boolean) = settingsManager.setVoiceStopEnabled(enabled) - fun setSafeWord(word: String?) = settingsManager.setSafeWord(word) - fun setSafeWordCalibrated(calibrated: Boolean) = settingsManager.setSafeWordCalibrated(calibrated) - fun setVelocityLossThreshold(percent: Int) = settingsManager.setVelocityLossThreshold(percent) - fun setAutoEndOnVelocityLoss(enabled: Boolean) = settingsManager.setAutoEndOnVelocityLoss(enabled) - fun setWeightSuggestionsEnabled(enabled: Boolean) = settingsManager.setWeightSuggestionsEnabled(enabled) - - // Issue #517: system-wide default scaling basis - fun setDefaultScalingBasis(basis: ScalingBasis) = settingsManager.setDefaultScalingBasis(basis) - - // Issue #595: routine-builder defaults for newly added cable exercises - fun setDefaultRoutineExerciseUsePercentOfPR(enabled: Boolean) = - settingsManager.setDefaultRoutineExerciseUsePercentOfPR(enabled) - - fun setDefaultRoutineExerciseWeightPercentOfPR(percent: Int) = - settingsManager.setDefaultRoutineExerciseWeightPercentOfPR(percent) - - // Issue #611: Verbal encouragement + opt-in vulgar mode + Dominatrix mode + 18+ gate - fun setVerbalEncouragementEnabled(enabled: Boolean) = - settingsManager.setVerbalEncouragementEnabled(enabled) - fun setVulgarModeEnabled(enabled: Boolean) = - settingsManager.setVulgarModeEnabled(enabled) - fun setVulgarTier(tier: VulgarTier) = - settingsManager.setVulgarTier(tier) - fun setDominatrixModeUnlocked(unlocked: Boolean) = - settingsManager.setDominatrixModeUnlocked(unlocked) - fun setDominatrixModeActive(active: Boolean) = - settingsManager.setDominatrixModeActive(active) - fun setAdultsOnlyConfirmed(confirmed: Boolean) = - settingsManager.setAdultsOnlyConfirmed(confirmed) - - // Issue #611 (PR-followup #613): one-shot decline-remember gate for the 18+ - // Adults Only modal. Thin wrapper around SettingsManager; used by SettingsTab's - // modal decline path so the modal never re-prompts for this install. - fun setAdultsOnlyPrompted(prompted: Boolean) = - settingsManager.setAdultsOnlyPrompted(prompted) - fun isAdultsOnlyPrompted(): Boolean = - settingsManager.isAdultsOnlyPrompted() - // Backup stats for Settings UI private val _backupStats = kotlinx.coroutines.flow.MutableStateFlow(null) val backupStats: kotlinx.coroutines.flow.StateFlow = _backupStats @@ -691,13 +659,6 @@ class MainViewModel constructor( val discoModeActive: StateFlow = bleRepository.discoModeActive - fun unlockDiscoMode() { - viewModelScope.launch { - preferencesManager.setDiscoModeUnlocked(true) - Logger.i { "DISCO MODE UNLOCKED!" } - } - } - fun toggleDiscoMode(enabled: Boolean) { if (enabled) { bleRepository.startDiscoMode() diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileSwitcherViewModel.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileSwitcherViewModel.kt new file mode 100644 index 000000000..1c544c48c --- /dev/null +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileSwitcherViewModel.kt @@ -0,0 +1,205 @@ +package com.devil.phoenixproject.presentation.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import co.touchlab.kermit.Logger +import com.devil.phoenixproject.data.repository.ActiveProfileContext +import com.devil.phoenixproject.data.repository.ProfileContextRecoveryException +import com.devil.phoenixproject.data.repository.UserProfileRepository +import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +enum class RootProfileOperationKind { SWITCH, CREATE, RECOVERY } + +data class RootProfileOperation( + val token: Long, + val kind: RootProfileOperationKind, + val targetProfileId: String? = null, +) + +enum class ProfileOverlayError { + SWITCH_FAILED, + CREATE_FAILED, + RECOVERY_RETRY_FAILED, +} + +data class ProfileSwitcherUiState( + val showSwitcher: Boolean = false, + val showAddDialog: Boolean = false, + val recoveryRequired: Boolean = false, + val operation: RootProfileOperation? = null, + val error: ProfileOverlayError? = null, +) + +class ProfileSwitcherViewModel( + private val profiles: UserProfileRepository, +) : ViewModel() { + private val _uiState = MutableStateFlow(ProfileSwitcherUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var nextToken = 0L + private var operationJob: Job? = null + + fun openSwitcher() { + _uiState.update { state -> + if (state.recoveryRequired) state else state.copy(showSwitcher = true, error = null) + } + } + + fun dismissSwitcher() { + if (profiles.activeProfileContext.value is ActiveProfileContext.Switching) return + _uiState.update { state -> + if (state.operation != null || state.recoveryRequired) { + state + } else { + ProfileSwitcherUiState() + } + } + } + + fun openAddDialog() { + if (profiles.activeProfileContext.value !is ActiveProfileContext.Ready) return + _uiState.update { state -> + if (!state.showSwitcher || state.operation != null || state.recoveryRequired) { + state + } else { + state.copy(showAddDialog = true, error = null) + } + } + } + + fun dismissAddDialog() { + if (profiles.activeProfileContext.value is ActiveProfileContext.Switching) return + _uiState.update { state -> + if (state.operation != null || state.recoveryRequired) { + state + } else { + state.copy(showAddDialog = false, error = null) + } + } + } + + fun switchProfile(profileId: String) { + val targetProfileId = profileId.trim() + if (targetProfileId.isEmpty()) return + val ready = profiles.activeProfileContext.value as? ActiveProfileContext.Ready ?: return + if (ready.profile.id == targetProfileId || !_uiState.value.showSwitcher) return + val operation = beginOperation(RootProfileOperationKind.SWITCH, targetProfileId) ?: return + launchOwned(operation) { + try { + profiles.setActiveProfile(targetProfileId) + finishOwned(operation.token) { ProfileSwitcherUiState() } + } catch (error: CancellationException) { + throw error + } catch (error: ProfileContextRecoveryException) { + enterRecoveryIfOwned(operation.token, error) + } catch (error: Exception) { + finishOwned(operation.token) { state -> + state.copy(operation = null, error = ProfileOverlayError.SWITCH_FAILED) + } + } + } + } + + fun createAndActivateProfile(name: String, colorIndex: Int) { + val trimmedName = name.trim() + if (trimmedName.isEmpty()) return + if (profiles.activeProfileContext.value !is ActiveProfileContext.Ready) return + if (!_uiState.value.showAddDialog) return + val operation = beginOperation(RootProfileOperationKind.CREATE) ?: return + launchOwned(operation) { + try { + profiles.createAndActivateProfile(trimmedName, colorIndex) + finishOwned(operation.token) { ProfileSwitcherUiState() } + } catch (error: CancellationException) { + throw error + } catch (error: ProfileContextRecoveryException) { + enterRecoveryIfOwned(operation.token, error) + } catch (error: Exception) { + finishOwned(operation.token) { state -> + state.copy(operation = null, error = ProfileOverlayError.CREATE_FAILED) + } + } + } + } + + fun requireRecovery(error: ProfileContextRecoveryException) { + enterRecovery(error) + } + + fun retryRecovery() { + val operation = beginOperation( + kind = RootProfileOperationKind.RECOVERY, + allowDuringRecovery = true, + ) ?: return + launchOwned(operation) { + try { + profiles.reconcileActiveProfileContext() + finishOwned(operation.token) { ProfileSwitcherUiState() } + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + finishOwned(operation.token) { state -> + state.copy( + operation = null, + recoveryRequired = true, + error = ProfileOverlayError.RECOVERY_RETRY_FAILED, + ) + } + } + } + } + + private fun beginOperation( + kind: RootProfileOperationKind, + targetProfileId: String? = null, + allowDuringRecovery: Boolean = false, + ): RootProfileOperation? { + val state = _uiState.value + if (state.operation != null) return null + if (state.recoveryRequired != allowDuringRecovery) return null + val operation = RootProfileOperation(++nextToken, kind, targetProfileId) + _uiState.value = state.copy(operation = operation, error = null) + return operation + } + + private fun launchOwned( + operation: RootProfileOperation, + block: suspend () -> Unit, + ) { + val job = viewModelScope.launch(start = CoroutineStart.LAZY) { + try { + block() + } finally { + finishOwned(operation.token) { state -> state.copy(operation = null) } + } + } + operationJob = job + job.start() + } + + private inline fun finishOwned( + token: Long, + transform: (ProfileSwitcherUiState) -> ProfileSwitcherUiState, + ) { + _uiState.update { state -> + if (state.operation?.token == token) transform(state) else state + } + } + + private fun enterRecoveryIfOwned(token: Long, error: ProfileContextRecoveryException) { + if (_uiState.value.operation?.token == token) enterRecovery(error) + } + + private fun enterRecovery(error: ProfileContextRecoveryException) { + Logger.e(error) { "PROFILE_CONTEXT: root recovery required" } + operationJob?.cancel() + _uiState.value = ProfileSwitcherUiState(recoveryRequired = true) + } +} diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileViewModel.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileViewModel.kt new file mode 100644 index 000000000..14817ab78 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileViewModel.kt @@ -0,0 +1,913 @@ +package com.devil.phoenixproject.presentation.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.devil.phoenixproject.data.integration.ExternalMeasurementRepository +import com.devil.phoenixproject.data.integration.HealthBodyWeightSyncManager +import com.devil.phoenixproject.data.repository.ActiveProfileContext +import com.devil.phoenixproject.data.repository.ExerciseRepository +import com.devil.phoenixproject.data.repository.MAX_RECENT_EXERCISE_SESSIONS +import com.devil.phoenixproject.data.repository.PersonalRecordRepository +import com.devil.phoenixproject.data.repository.ProfileContextRecoveryException +import com.devil.phoenixproject.data.repository.ProfileContextUnavailableException +import com.devil.phoenixproject.data.repository.StaleProfileContextException +import com.devil.phoenixproject.data.repository.UserProfileRepository +import com.devil.phoenixproject.data.repository.WorkoutRepository +import com.devil.phoenixproject.domain.model.CoreProfilePreferences +import com.devil.phoenixproject.domain.model.Exercise +import com.devil.phoenixproject.domain.model.LedPreferences +import com.devil.phoenixproject.domain.model.PRType +import com.devil.phoenixproject.domain.model.PersonalRecord +import com.devil.phoenixproject.domain.model.ProfileLocalSafetyPreferences +import com.devil.phoenixproject.domain.model.RackPreferences +import com.devil.phoenixproject.domain.model.VbtPreferences +import com.devil.phoenixproject.domain.model.WorkoutPreferences +import com.devil.phoenixproject.domain.model.WorkoutSession +import com.devil.phoenixproject.domain.usecase.CurrentOneRepMax +import com.devil.phoenixproject.domain.usecase.ResolveCurrentOneRepMaxUseCase +import com.devil.phoenixproject.presentation.components.canDeleteProfile +import com.devil.phoenixproject.presentation.components.ProfileMeasurementKey +import com.devil.phoenixproject.presentation.components.latestImportedBodyWeightMeasuredAt +import com.devil.phoenixproject.presentation.components.normalizedProfileColorIndex +import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.yield + +sealed interface ProfileLoadable { + data object Empty : ProfileLoadable + data object Loading : ProfileLoadable + data class Ready(val value: T) : ProfileLoadable + data class Failed(val cause: Throwable) : ProfileLoadable +} + +data class ProfilePrHighlights( + val maxWeightPerCableKg: Float?, + val estimatedOneRepMaxPerCableKg: Float?, + val maxVolumeKg: Float?, +) + +enum class ProfileIdentityMutationKind { UPDATE, DELETE } + +data class ProfileIdentityMutation( + val token: Long, + val profileId: String, + val kind: ProfileIdentityMutationKind, +) + +enum class ProfilePreferenceSection { + CORE, + RACK, + WORKOUT, + LED, + VBT, + LOCAL_SAFETY, +} + +enum class ProfilePreferenceMutationKind { + UPDATE, + ADULT_ENABLE, + ADULT_DECLINE, + DISCO_UNLOCK, + DOMINATRIX_UNLOCK, +} + +data class ProfilePreferenceMutation( + val token: Long, + val profileId: String, + val sections: Set, + val kind: ProfilePreferenceMutationKind, +) + +data class ProfileUiState( + val context: ActiveProfileContext? = null, + val selectedExercise: Exercise? = null, + val missingExerciseId: String? = null, + val selectionFailure: Throwable? = null, + val currentOneRepMax: ProfileLoadable = ProfileLoadable.Empty, + val prHighlights: ProfileLoadable = ProfileLoadable.Empty, + val recentSessions: ProfileLoadable> = ProfileLoadable.Empty, + val identityMutation: ProfileIdentityMutation? = null, + val preferenceMutations: + Map = emptyMap(), + val importedBodyWeightMeasuredAt: Long? = null, +) { + val identityMutationInFlight: Boolean + get() = identityMutation != null + + val busyPreferenceSections: Set + get() = preferenceMutations.keys +} + +sealed interface ProfileUiEvent { + data class PreferenceMutationSucceeded( + val profileId: String, + val token: Long, + val kind: ProfilePreferenceMutationKind, + val sections: Set, + ) : ProfileUiEvent + + data class PreferenceUpdateFailed( + val profileId: String, + val token: Long, + val kind: ProfilePreferenceMutationKind, + val sections: Set, + val committedSections: Set, + ) : ProfileUiEvent + + data class IdentityUpdateFailed( + val profileId: String, + val kind: ProfileIdentityMutationKind, + ) : ProfileUiEvent + data class IdentityUpdated(val profileId: String) : ProfileUiEvent + data class ProfileDeleted(val profileId: String) : ProfileUiEvent + data class ProfileRecoveryRequired( + val profileId: String, + val cause: ProfileContextRecoveryException, + ) : ProfileUiEvent +} + +class ProfileViewModel( + private val profiles: UserProfileRepository, + private val exercises: ExerciseRepository, + private val workouts: WorkoutRepository, + private val personalRecords: PersonalRecordRepository, + private val resolveCurrentOneRepMax: ResolveCurrentOneRepMaxUseCase, + private val externalMeasurements: ExternalMeasurementRepository, +) : ViewModel() { + private val _uiState = MutableStateFlow(ProfileUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + private val _events = Channel(Channel.BUFFERED) + val events: Flow = _events.receiveAsFlow() + + private val selectedExerciseIds = mutableMapOf() + private var resolvedSelectionProfileId: String? = null + private var insightsJob: Job? = null + private var identityJob: Job? = null + private var nextIdentityToken = 0L + private var nextPreferenceToken = 0L + private var measurementJob: Job? = null + private var currentMeasurementKey: ProfileMeasurementKey? = null + private var currentMeasurementToken = 0L + private var nextMeasurementToken = 0L + + init { + viewModelScope.launch { + profiles.activeProfileContext.collectLatest { context -> + when (context) { + is ActiveProfileContext.Switching -> { + insightsJob?.cancel() + if (_uiState.value.identityMutation?.kind == ProfileIdentityMutationKind.UPDATE) { + identityJob?.cancel() + } + invalidateMeasurementAttribution() + resolvedSelectionProfileId = null + _uiState.update { state -> + ProfileUiState( + context = context, + identityMutation = state.identityMutation, + preferenceMutations = state.preferenceMutations, + ) + } + } + + is ActiveProfileContext.Ready -> applyReadyContext(context) + } + } + } + } + + fun selectExercise(exercise: Exercise) { + val exerciseId = exercise.id?.takeIf { it.isNotBlank() } ?: return + val uiReady = uiState.value.context as? ActiveProfileContext.Ready ?: return + val repositoryReady = + profiles.activeProfileContext.value as? ActiveProfileContext.Ready ?: return + val profileId = uiReady.profile.id + if (repositoryReady.profile.id != profileId) return + + selectedExerciseIds[profileId] = exerciseId + resolvedSelectionProfileId = profileId + updateIfProfileCurrent(profileId) { state -> + state.copy( + selectedExercise = exercise, + missingExerciseId = null, + selectionFailure = null, + ) + } + if (isSelectionCurrent(profileId, exerciseId)) { + loadInsights(profileId, exercise) + } + } + + fun updateCore(value: CoreProfilePreferences): Long? = startSinglePreferenceMutation( + section = ProfilePreferenceSection.CORE, + ) { profileId -> + profiles.updateCore(profileId, value) + } + + fun updateRack(value: RackPreferences): Long? = startSinglePreferenceMutation( + section = ProfilePreferenceSection.RACK, + ) { profileId -> + profiles.updateRack(profileId, value) + } + + fun updateWorkout(value: WorkoutPreferences): Long? = startSinglePreferenceMutation( + section = ProfilePreferenceSection.WORKOUT, + ) { profileId -> + profiles.updateWorkout(profileId, value) + } + + fun updateLed(value: LedPreferences): Long? = startSinglePreferenceMutation( + section = ProfilePreferenceSection.LED, + ) { profileId -> + profiles.updateLed(profileId, value) + } + + fun updateVbt(value: VbtPreferences): Long? = startSinglePreferenceMutation( + section = ProfilePreferenceSection.VBT, + ) { profileId -> + profiles.updateVbt(profileId, value) + } + + fun updateLocalSafety(value: ProfileLocalSafetyPreferences): Long? = + startSinglePreferenceMutation( + section = ProfilePreferenceSection.LOCAL_SAFETY, + ) { profileId -> + profiles.updateLocalSafety(profileId, value) + } + + fun confirmAdultsOnlyAndEnableVulgar(): Long? { + val ready = currentReadyForPreferenceMutation() ?: return null + val sections = when { + !ready.localSafety.adultsOnlyConfirmed -> setOf( + ProfilePreferenceSection.LOCAL_SAFETY, + ProfilePreferenceSection.VBT, + ) + !ready.preferences.vbt.value.vulgarModeEnabled -> setOf( + ProfilePreferenceSection.VBT, + ) + else -> return null + } + return startPreferenceMutation( + sections = sections, + kind = ProfilePreferenceMutationKind.ADULT_ENABLE, + ) { mutation, committedSections -> + var authoritative = refreshAuthoritativeReady(mutation) + if (ProfilePreferenceSection.LOCAL_SAFETY in mutation.sections) { + val confirmed = authoritative.localSafety.copy( + adultsOnlyConfirmed = true, + adultsOnlyPrompted = true, + ) + authoritative = commitPreferenceWrite( + mutation = mutation, + section = ProfilePreferenceSection.LOCAL_SAFETY, + committedSections = committedSections, + ) { + profiles.updateLocalSafety(mutation.profileId, confirmed) + } + } + if (ProfilePreferenceSection.VBT in mutation.sections) { + val enabled = authoritative.preferences.vbt.value.copy( + vulgarModeEnabled = true, + ) + commitPreferenceWrite( + mutation = mutation, + section = ProfilePreferenceSection.VBT, + committedSections = committedSections, + ) { + profiles.updateVbt(mutation.profileId, enabled) + } + } + } + } + + fun declineAdultsOnly(): Long? = startPreferenceMutation( + sections = setOf( + ProfilePreferenceSection.VBT, + ProfilePreferenceSection.LOCAL_SAFETY, + ), + kind = ProfilePreferenceMutationKind.ADULT_DECLINE, + ) { mutation, committedSections -> + var authoritative = refreshAuthoritativeReady(mutation) + val failClosed = authoritative.preferences.vbt.value.copy( + vulgarModeEnabled = false, + dominatrixModeActive = false, + ) + authoritative = commitPreferenceWrite( + mutation = mutation, + section = ProfilePreferenceSection.VBT, + committedSections = committedSections, + ) { + profiles.updateVbt(mutation.profileId, failClosed) + } + val declined = authoritative.localSafety.copy( + adultsOnlyConfirmed = false, + adultsOnlyPrompted = true, + ) + commitPreferenceWrite( + mutation = mutation, + section = ProfilePreferenceSection.LOCAL_SAFETY, + committedSections = committedSections, + ) { + profiles.updateLocalSafety(mutation.profileId, declined) + } + } + + fun unlockDiscoMode(): Long? { + val ready = currentReadyForPreferenceMutation() ?: return null + if (ready.preferences.led.value.discoModeUnlocked) return null + return startPreferenceMutation( + sections = setOf(ProfilePreferenceSection.LED), + kind = ProfilePreferenceMutationKind.DISCO_UNLOCK, + ) { mutation, committedSections -> + val authoritative = refreshAuthoritativeReady(mutation) + check(!authoritative.preferences.led.value.discoModeUnlocked) + val unlocked = authoritative.preferences.led.value.copy( + discoModeUnlocked = true, + ) + commitPreferenceWrite( + mutation = mutation, + section = ProfilePreferenceSection.LED, + committedSections = committedSections, + ) { + profiles.updateLed(mutation.profileId, unlocked) + } + } + } + + fun unlockDominatrixMode(): Long? { + val ready = currentReadyForPreferenceMutation() ?: return null + if (!isDominatrixUnlockEligible(ready)) return null + return startPreferenceMutation( + sections = setOf(ProfilePreferenceSection.VBT), + kind = ProfilePreferenceMutationKind.DOMINATRIX_UNLOCK, + ) { mutation, committedSections -> + val authoritative = refreshAuthoritativeReady(mutation) + check(isDominatrixUnlockEligible(authoritative)) + val unlocked = authoritative.preferences.vbt.value.copy( + dominatrixModeUnlocked = true, + ) + commitPreferenceWrite( + mutation = mutation, + section = ProfilePreferenceSection.VBT, + committedSections = committedSections, + ) { + profiles.updateVbt(mutation.profileId, unlocked) + } + } + } + + fun updateIdentity(name: String, colorIndex: Int) { + val trimmedName = name.trim() + if (trimmedName.isEmpty()) return + val profileId = currentMutationProfileId() ?: return + startIdentityMutation(profileId, ProfileIdentityMutationKind.UPDATE) { + if (currentMutationProfileId() != profileId) return@startIdentityMutation null + profiles.updateProfile( + id = profileId, + name = trimmedName, + colorIndex = normalizedProfileColorIndex(colorIndex), + ) + if (currentMutationProfileId() == profileId) { + ProfileUiEvent.IdentityUpdated(profileId) + } else { + null + } + } + } + + fun deleteActiveProfile() { + val uiReady = uiState.value.context as? ActiveProfileContext.Ready ?: return + val profileId = currentMutationProfileId() ?: return + if (!canDeleteProfile(uiReady.profile)) return + startIdentityMutation(profileId, ProfileIdentityMutationKind.DELETE) { + if (profiles.deleteActiveProfile(profileId)) { + ProfileUiEvent.ProfileDeleted(profileId) + } else { + ProfileUiEvent.IdentityUpdateFailed( + profileId, + ProfileIdentityMutationKind.DELETE, + ) + } + } + } + + private fun startSinglePreferenceMutation( + section: ProfilePreferenceSection, + write: suspend (String) -> Unit, + ): Long? = startPreferenceMutation( + sections = setOf(section), + kind = ProfilePreferenceMutationKind.UPDATE, + ) { mutation, committedSections -> + commitPreferenceWrite( + mutation = mutation, + section = section, + committedSections = committedSections, + ) { + write(mutation.profileId) + } + } + + private fun startPreferenceMutation( + sections: Set, + kind: ProfilePreferenceMutationKind, + action: suspend ( + ProfilePreferenceMutation, + MutableSet, + ) -> Unit, + ): Long? { + val mutation = claimPreferenceMutation(sections, kind) ?: return null + val job = viewModelScope.launch(start = CoroutineStart.LAZY) { + val committedSections = linkedSetOf() + var terminalEvent: ProfileUiEvent? = null + try { + // Even Main.immediate must return the accepted token before a terminal event can + // fire, while cancellation at this yield still reaches owner cleanup below. + yield() + action(mutation, committedSections) + terminalEvent = ProfileUiEvent.PreferenceMutationSucceeded( + profileId = mutation.profileId, + token = mutation.token, + kind = mutation.kind, + sections = mutation.sections, + ) + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + terminalEvent = ProfileUiEvent.PreferenceUpdateFailed( + profileId = mutation.profileId, + token = mutation.token, + kind = mutation.kind, + sections = mutation.sections, + committedSections = committedSections.toSet(), + ) + } finally { + val ownsMutation = clearPreferenceMutation(mutation.token) + if (ownsMutation) terminalEvent?.let(_events::trySend) + } + } + job.start() + return mutation.token + } + + private fun claimPreferenceMutation( + sections: Set, + kind: ProfilePreferenceMutationKind, + ): ProfilePreferenceMutation? { + require(sections.isNotEmpty()) + val profileId = currentMutationProfileId() ?: return null + val mutation = ProfilePreferenceMutation( + token = ++nextPreferenceToken, + profileId = profileId, + sections = sections.toSet(), + kind = kind, + ) + while (true) { + val state = _uiState.value + val ready = state.context as? ActiveProfileContext.Ready ?: return null + if ( + state.identityMutation != null || + sections.any(state.preferenceMutations::containsKey) || + ready.profile.id != profileId || + currentMutationProfileId() != profileId + ) { + return null + } + + val claimed = state.copy( + preferenceMutations = state.preferenceMutations + + sections.associateWith { mutation }, + ) + if (_uiState.compareAndSet(state, claimed)) return mutation + } + } + + private fun clearPreferenceMutation(token: Long): Boolean { + while (true) { + val state = _uiState.value + if (state.preferenceMutations.values.none { it.token == token }) return false + val cleared = state.copy( + preferenceMutations = state.preferenceMutations.filterValues { + it.token != token + }, + ) + if (_uiState.compareAndSet(state, cleared)) return true + } + } + + private suspend fun commitPreferenceWrite( + mutation: ProfilePreferenceMutation, + section: ProfilePreferenceSection, + committedSections: MutableSet, + write: suspend () -> Unit, + ): ActiveProfileContext.Ready { + write() + committedSections += section + return refreshAuthoritativeReady(mutation) + } + + private suspend fun refreshAuthoritativeReady( + mutation: ProfilePreferenceMutation, + ): ActiveProfileContext.Ready { + while (true) { + val ready = requireAuthoritativeReady(mutation.profileId) + applyReadyContext(ready) + val repositoryReady = requireAuthoritativeReady(mutation.profileId) + if (repositoryReady != ready) continue + val uiContext = _uiState.value.context + val uiReady = uiContext as? ActiveProfileContext.Ready + ?: throw ProfileContextUnavailableException() + if (uiReady.profile.id != mutation.profileId) { + throw StaleProfileContextException(mutation.profileId, uiReady.profile.id) + } + if (uiReady != repositoryReady) continue + return repositoryReady + } + } + + private fun requireAuthoritativeReady(profileId: String): ActiveProfileContext.Ready = + when (val context = profiles.activeProfileContext.value) { + is ActiveProfileContext.Switching -> throw ProfileContextUnavailableException() + is ActiveProfileContext.Ready -> { + if (context.profile.id != profileId) { + throw StaleProfileContextException(profileId, context.profile.id) + } + context + } + } + + private fun currentReadyForPreferenceMutation(): ActiveProfileContext.Ready? { + val profileId = currentMutationProfileId() ?: return null + val ready = profiles.activeProfileContext.value as? ActiveProfileContext.Ready ?: return null + return ready.takeIf { it.profile.id == profileId } + } + + private fun isDominatrixUnlockEligible(context: ActiveProfileContext.Ready): Boolean { + val vbt = context.preferences.vbt.value + return context.localSafety.adultsOnlyConfirmed && + vbt.enabled && + vbt.verbalEncouragementEnabled && + vbt.vulgarModeEnabled && + !vbt.dominatrixModeUnlocked + } + + private fun currentMutationProfileId(): String? { + val uiReady = uiState.value.context as? ActiveProfileContext.Ready ?: return null + val repositoryReady = + profiles.activeProfileContext.value as? ActiveProfileContext.Ready ?: return null + return uiReady.profile.id.takeIf { + it.isNotBlank() && + it == repositoryReady.profile.id && + uiReady.profile.isActive && + repositoryReady.profile.isActive + } + } + + private fun startIdentityMutation( + profileId: String, + kind: ProfileIdentityMutationKind, + action: suspend () -> ProfileUiEvent?, + ) { + val mutation = ProfileIdentityMutation( + token = ++nextIdentityToken, + profileId = profileId, + kind = kind, + ) + while (true) { + val state = _uiState.value + val ready = state.context as? ActiveProfileContext.Ready ?: return + if ( + state.identityMutation != null || + state.preferenceMutations.isNotEmpty() || + ready.profile.id != profileId || + currentMutationProfileId() != profileId + ) { + return + } + if (_uiState.compareAndSet(state, state.copy(identityMutation = mutation))) break + } + + val job = viewModelScope.launch(start = CoroutineStart.LAZY) { + var terminalEvent: ProfileUiEvent? = null + try { + terminalEvent = action() + } catch (error: CancellationException) { + throw error + } catch (error: ProfileContextRecoveryException) { + terminalEvent = ProfileUiEvent.ProfileRecoveryRequired(profileId, error) + } catch (error: Exception) { + terminalEvent = ProfileUiEvent.IdentityUpdateFailed(profileId, kind) + } finally { + val ownsMutation = clearIdentityMutation(mutation.token) + val currentEnoughToPublish = + kind != ProfileIdentityMutationKind.UPDATE || currentMutationProfileId() == profileId + if (ownsMutation && currentEnoughToPublish) { + terminalEvent?.let(_events::trySend) + } + } + } + identityJob = job + job.start() + } + + private fun clearIdentityMutation(token: Long): Boolean { + var cleared = false + _uiState.update { state -> + if (state.identityMutation?.token == token) { + cleared = true + state.copy(identityMutation = null) + } else { + state + } + } + return cleared + } + + private suspend fun applyReadyContext(context: ActiveProfileContext.Ready) { + val profileId = context.profile.id + val currentProfileId = + (_uiState.value.context as? ActiveProfileContext.Ready)?.profile?.id + + if (currentProfileId == profileId && resolvedSelectionProfileId == profileId) { + updateIfProfileCurrent(profileId) { state -> state.copy(context = context) } + restartMeasurementAttribution(context) + return + } + + insightsJob?.cancel() + _uiState.update { state -> + ProfileUiState( + context = context, + identityMutation = state.identityMutation, + preferenceMutations = state.preferenceMutations, + ) + } + restartMeasurementAttribution(context) + try { + require(profileId.isNotBlank()) { "Ready profile ID must not be blank" } + val savedId = selectedExerciseIds[profileId] + val saved = savedId?.let { validExercise(it) } + val fallbackId = if (saved == null) { + workouts.getMostRecentCompletedExerciseId(profileId) + } else { + null + } + val fallback = fallbackId?.let { validExercise(it) } + val selected = saved ?: fallback + if (!isProfileCurrent(profileId)) return + + resolvedSelectionProfileId = profileId + selected?.id?.let { selectedExerciseIds[profileId] = it } + updateIfProfileCurrent(profileId) { state -> + state.copy( + context = context, + selectedExercise = selected, + missingExerciseId = (fallbackId ?: savedId) + ?.takeIf { it.isNotBlank() && selected == null }, + selectionFailure = null, + ) + } + selected?.let { loadInsights(profileId, it) } + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + resolvedSelectionProfileId = null + updateIfProfileCurrent(profileId) { state -> + state.copy( + context = context, + selectedExercise = null, + missingExerciseId = null, + selectionFailure = error, + ) + } + } + } + + private fun restartMeasurementAttribution(context: ActiveProfileContext.Ready) { + val key = ProfileMeasurementKey( + profileId = context.profile.id, + coreLocalGeneration = context.preferences.core.metadata.localGeneration, + bodyWeightKg = context.preferences.core.value.bodyWeightKg, + ) + if (currentMeasurementKey == key) return + + val token = ++nextMeasurementToken + currentMeasurementToken = token + currentMeasurementKey = key + measurementJob?.cancel() + _uiState.update { state -> state.copy(importedBodyWeightMeasuredAt = null) } + measurementJob = viewModelScope.launch { + externalMeasurements.observeMeasurementsByType( + profileId = key.profileId, + measurementType = HealthBodyWeightSyncManager.MEASUREMENT_TYPE_WEIGHT, + ).collect { measurements -> + val measuredAt = latestImportedBodyWeightMeasuredAt( + profileId = key.profileId, + bodyWeightKg = key.bodyWeightKg, + measurements = measurements, + ) + publishMeasurementAttribution(token, key, measuredAt) + } + } + } + + private fun invalidateMeasurementAttribution() { + currentMeasurementToken = ++nextMeasurementToken + currentMeasurementKey = null + measurementJob?.cancel() + measurementJob = null + _uiState.update { state -> state.copy(importedBodyWeightMeasuredAt = null) } + } + + private fun publishMeasurementAttribution( + token: Long, + key: ProfileMeasurementKey, + measuredAt: Long?, + ) { + _uiState.update { state -> + val repositoryReady = + profiles.activeProfileContext.value as? ActiveProfileContext.Ready + val uiReady = state.context as? ActiveProfileContext.Ready + val repositoryCore = repositoryReady?.preferences?.core + val uiCore = uiReady?.preferences?.core + if ( + currentMeasurementToken == token && + currentMeasurementKey == key && + repositoryReady?.profile?.id == key.profileId && + uiReady?.profile?.id == key.profileId && + repositoryCore?.metadata?.localGeneration == key.coreLocalGeneration && + uiCore?.metadata?.localGeneration == key.coreLocalGeneration && + repositoryCore?.value?.bodyWeightKg == key.bodyWeightKg && + uiCore?.value?.bodyWeightKg == key.bodyWeightKg + ) { + state.copy(importedBodyWeightMeasuredAt = measuredAt) + } else { + state + } + } + } + + private suspend fun validExercise(requestedId: String): Exercise? { + if (requestedId.isBlank()) return null + return exercises.getExerciseById(requestedId) + ?.takeIf { it.id == requestedId && !it.id.isNullOrBlank() } + } + + private fun loadInsights(profileId: String, exercise: Exercise) { + val exerciseId = exercise.id?.takeIf { it.isNotBlank() } ?: return + insightsJob?.cancel() + updateIfSelectionCurrent(profileId, exerciseId) { state -> + state.copy( + currentOneRepMax = ProfileLoadable.Loading, + prHighlights = ProfileLoadable.Loading, + recentSessions = ProfileLoadable.Loading, + ) + } + if (!isSelectionCurrent(profileId, exerciseId)) return + + insightsJob = viewModelScope.launch { + supervisorScope { + launch { + loadBranch( + profileId = profileId, + exerciseId = exerciseId, + load = { + resolveCurrentOneRepMax(exerciseId, profileId) + ?.let { ProfileLoadable.Ready(it) } + ?: ProfileLoadable.Empty + }, + publish = { state, value -> state.copy(currentOneRepMax = value) }, + ) + } + launch { + loadBranch( + profileId = profileId, + exerciseId = exerciseId, + load = { + ProfileLoadable.Ready( + personalRecords.getAllPRsForExercise(exerciseId, profileId) + .toHighlights(), + ) + }, + publish = { state, value -> state.copy(prHighlights = value) }, + ) + } + launch { + loadBranch( + profileId = profileId, + exerciseId = exerciseId, + load = { + ProfileLoadable.Ready( + workouts.getRecentCompletedSessionsForExercise( + exerciseId = exerciseId, + profileId = profileId, + limit = MAX_RECENT_EXERCISE_SESSIONS, + ), + ) + }, + publish = { state, value -> state.copy(recentSessions = value) }, + ) + } + } + } + } + + private suspend fun loadBranch( + profileId: String, + exerciseId: String, + load: suspend () -> ProfileLoadable, + publish: (ProfileUiState, ProfileLoadable) -> ProfileUiState, + ) { + val result: ProfileLoadable = try { + load() + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + ProfileLoadable.Failed(error) + } + updateIfSelectionCurrent(profileId, exerciseId) { state -> + publish(state, result) + } + } + + private fun isProfileCurrent(profileId: String): Boolean { + val repositoryReady = + profiles.activeProfileContext.value as? ActiveProfileContext.Ready ?: return false + val uiReady = uiState.value.context as? ActiveProfileContext.Ready ?: return false + return repositoryReady.profile.id == profileId && uiReady.profile.id == profileId + } + + private fun isSelectionCurrent(profileId: String, exerciseId: String): Boolean = + isProfileCurrent(profileId) && + uiState.value.selectedExercise?.id == exerciseId && + selectedExerciseIds[profileId] == exerciseId + + private inline fun updateIfProfileCurrent( + profileId: String, + transform: (ProfileUiState) -> ProfileUiState, + ) { + _uiState.update { state -> + val repositoryReady = + profiles.activeProfileContext.value as? ActiveProfileContext.Ready + val uiReady = state.context as? ActiveProfileContext.Ready + if ( + repositoryReady?.profile?.id == profileId && + uiReady?.profile?.id == profileId + ) { + transform(state) + } else { + state + } + } + } + + private inline fun updateIfSelectionCurrent( + profileId: String, + exerciseId: String, + transform: (ProfileUiState) -> ProfileUiState, + ) { + _uiState.update { state -> + val repositoryReady = + profiles.activeProfileContext.value as? ActiveProfileContext.Ready + val uiReady = state.context as? ActiveProfileContext.Ready + if ( + repositoryReady?.profile?.id == profileId && + uiReady?.profile?.id == profileId && + state.selectedExercise?.id == exerciseId && + selectedExerciseIds[profileId] == exerciseId + ) { + transform(state) + } else { + state + } + } + } +} + +private fun List.toHighlights() = ProfilePrHighlights( + maxWeightPerCableKg = asSequence() + .filter { it.prType == PRType.MAX_WEIGHT } + .map { it.weightPerCableKg } + .filter { it.isFinite() && it > 0f } + .maxOrNull(), + estimatedOneRepMaxPerCableKg = asSequence() + .map { it.oneRepMax } + .filter { it.isFinite() && it > 0f } + .maxOrNull(), + maxVolumeKg = asSequence() + .filter { it.prType == PRType.MAX_VOLUME } + .map { it.volume } + .filter { it.isFinite() && it > 0f } + .maxOrNull(), +) diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BackupModels.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BackupModels.kt index d72cdd7e8..973756357 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BackupModels.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BackupModels.kt @@ -1,7 +1,8 @@ package com.devil.phoenixproject.util -import com.devil.phoenixproject.domain.model.RackItem +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement /** * Sanitize eccentric load values to prevent machine faults. @@ -442,7 +443,9 @@ enum class BackupPhase(val displayName: String) { * progression overrides. Older (v1) backups remain importable — new fields default * to null via kotlinx.serialization default values. * - v3: adds RoutineGroup table and Routine.groupId field for routine grouping. - * - v4: adds equipment rack definitions and per-routine-exercise rack defaults. + * - v4: adds legacy global equipment rack definitions and per-routine-exercise rack defaults. + * - v5: replaces the global rack payload with independently restorable profile training + * preference sections. Local safety/consent state and sync bookkeeping are excluded. */ @Serializable data class BackupData( @@ -467,14 +470,29 @@ data class BackupPrivacyMetadata( val containsSessionNotes: Boolean = true, val containsAuthTokens: Boolean = false, val containsRuntimeSecrets: Boolean = false, - val userFacingSummary: String = "Full personal-data export: includes workout history, routines, profiles, personal records, and notes. Does not include auth tokens or runtime secrets.", + val userFacingSummary: String = "Full personal-data export: includes workout history, routines, profiles, profile training preferences, personal records, and notes. Does not include auth tokens, runtime secrets, preference sync bookkeeping, voice phrase or calibration, or adult consent and prompt state.", ) /** * Highest backup schema version this build can produce. * Bump whenever BackupContent gains/loses entities or a backup field type changes. */ -const val CURRENT_BACKUP_VERSION: Int = 4 +const val CURRENT_BACKUP_VERSION: Int = 5 + +/** + * Profile-scoped preference payload. Raw JSON sections intentionally isolate malformed or + * forward-version values so one bad section cannot prevent valid siblings from restoring. + * Sync metadata and local-only safety state are deliberately absent from this wire type. + */ +@Serializable +data class ProfilePreferencesBackup( + val profileId: String, + val core: JsonElement? = null, + val rack: JsonElement? = null, + val workout: JsonElement? = null, + val led: JsonElement? = null, + val vbt: JsonElement? = null, +) /** * Container for all backup data entities @@ -499,8 +517,11 @@ data class BackupContent( val streakHistory: List = emptyList(), val gamificationStats: GamificationStatsBackup? = null, val userProfiles: List = emptyList(), - // Added v4: local equipment rack definitions from Settings-backed repository. - val equipmentRackItems: List = emptyList(), + // Added v5: profile-scoped training preferences; sections decode independently. + val profilePreferences: List = emptyList(), + // Added v4 and retained only for deterministic v4 import compatibility. + @SerialName("equipmentRackItems") + val legacyEquipmentRackItems: JsonElement? = null, // Added v2: portal session notes (migration 26) val sessionNotes: List = emptyList(), // Added v3: routine groups (migration 27) diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/Constants.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/Constants.kt index 288d0047c..d3f2a5360 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/Constants.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/Constants.kt @@ -5,7 +5,7 @@ package com.devil.phoenixproject.util */ object Constants { // App version - const val APP_VERSION = "0.9.5" + const val APP_VERSION = "0.9.6" // EULA version - increment when EULA text changes materially // Users must re-accept when this version increases diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.kt index 956749e86..52db73464 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.kt @@ -1,7 +1,9 @@ package com.devil.phoenixproject.util import co.touchlab.kermit.Logger -import com.devil.phoenixproject.data.repository.EquipmentRackRepository +import com.devil.phoenixproject.data.preferences.ProfilePreferencesValidator +import com.devil.phoenixproject.data.repository.ProfilePreferencesRepository +import com.devil.phoenixproject.data.repository.UserProfileRepository import com.devil.phoenixproject.database.CompletedSet import com.devil.phoenixproject.database.CycleDay import com.devil.phoenixproject.database.CycleProgress @@ -22,13 +24,28 @@ import com.devil.phoenixproject.database.TrainingCycle import com.devil.phoenixproject.database.UserProfile import com.devil.phoenixproject.database.VitruvianDatabase import com.devil.phoenixproject.database.WorkoutSession +import com.devil.phoenixproject.domain.model.CoreProfilePreferences +import com.devil.phoenixproject.domain.model.LedPreferences +import com.devil.phoenixproject.domain.model.ProfilePreferenceSection +import com.devil.phoenixproject.domain.model.ProfilePreferenceValidity import com.devil.phoenixproject.domain.model.RackItem +import com.devil.phoenixproject.domain.model.RackPreferences +import com.devil.phoenixproject.domain.model.UserProfilePreferences +import com.devil.phoenixproject.domain.model.VbtPreferences +import com.devil.phoenixproject.domain.model.WorkoutPreferences import com.devil.phoenixproject.domain.model.generateUUID import com.devil.phoenixproject.util.BaseDataBackupManager.Companion.IMPORT_BATCH_SIZE import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.withContext +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.jsonObject /** * Platform-agnostic interface for backup/restore operations. @@ -114,13 +131,15 @@ interface DataBackupManager { */ abstract class BaseDataBackupManager( private val database: VitruvianDatabase, - private val equipmentRackRepository: EquipmentRackRepository, + private val profilePreferencesRepository: ProfilePreferencesRepository, + private val userProfileRepository: UserProfileRepository, ) : DataBackupManager { protected val json = Json { prettyPrint = false ignoreUnknownKeys = true // Forward compatibility encodeDefaults = true + explicitNulls = false } private val queries get() = database.vitruvianDatabaseQueries @@ -184,6 +203,32 @@ abstract class BaseDataBackupManager( val uniqueRoutineNameByExerciseName: Map, ) + private data class DeferredProfileRestore( + val backupVersion: Int, + val profilePreferences: List, + val legacyRackFieldPresent: Boolean, + val legacyRackElement: JsonElement?, + val representedProfileIds: Set, + ) + + private inline fun Json.encodeValidBackupSection( + section: ProfilePreferenceSection, + ): JsonElement? = if (section.validity is ProfilePreferenceValidity.Valid) { + encodeToJsonElement(section.value) + } else { + null + } + + private fun UserProfilePreferences.toBackup(): ProfilePreferencesBackup = + ProfilePreferencesBackup( + profileId = profileId, + core = json.encodeValidBackupSection(core), + rack = json.encodeValidBackupSection(rack), + workout = json.encodeValidBackupSection(workout), + led = json.encodeValidBackupSection(led), + vbt = json.encodeValidBackupSection(vbt), + ) + // -- Streaming export (Discussion #244 OOM fix) -- override suspend fun exportToFile(onProgress: (BackupProgress) -> Unit): Result = withContext(Dispatchers.IO) { @@ -254,14 +299,18 @@ abstract class BaseDataBackupManager( val earnedBadges = runCatching { queries.selectAllEarnedBadgesSync().executeAsList() }.getOrElse { emptyList() } val streakHistory = runCatching { queries.selectAllStreakHistorySync().executeAsList() }.getOrElse { emptyList() } val gamificationStats = runCatching { queries.selectGamificationStatsSync().executeAsOneOrNull() }.getOrNull() - val userProfiles = runCatching { queries.selectAllUserProfilesSync().executeAsList() }.getOrElse { emptyList() } + // Profile identities are a required root of a complete backup. Never emit an + // apparently successful empty identity/preference payload after a query failure. + val userProfiles = queries.selectAllUserProfilesSync().executeAsList() + val profilePreferences = userProfiles.map { profile -> + profilePreferencesRepository.get(profile.id).toBackup() + } // Migration 26 added SessionNotes. Wrap in runCatching for safety on legacy DBs // where the table does not yet exist (pre-flight create-if-missing covers this // on current builds, but defending against partial upgrade paths is cheap). val sessionNotes = runCatching { queries.selectAllSessionNotesSync().executeAsList() }.getOrElse { emptyList() } // Migration 27 added RoutineGroup. Same defensive pattern. val routineGroups = runCatching { queries.selectAllRoutineGroupsSync().executeAsList() }.getOrElse { emptyList() } - val equipmentRackItems = equipmentRackRepository.getItems() val nowMs = KmpUtils.currentTimeMillis() BackupData( @@ -287,7 +336,7 @@ abstract class BaseDataBackupManager( streakHistory = streakHistory.map { mapStreakHistoryToBackup(it) }, gamificationStats = gamificationStats?.let { mapGamificationStatsToBackup(it) }, userProfiles = userProfiles.map { mapUserProfileToBackup(it) }, - equipmentRackItems = equipmentRackItems, + profilePreferences = profilePreferences, sessionNotes = sessionNotes.map { mapSessionNotesToBackup(it) }, routineGroups = routineGroups.map { mapRoutineGroupToBackup(it) }, ), @@ -299,9 +348,21 @@ abstract class BaseDataBackupManager( } override suspend fun importFromJson(jsonString: String): Result = withContext(Dispatchers.IO) { + var preImportActiveProfileId: String? = null + val representedProfileIds = linkedSetOf() + var databaseWorkCommitted = false + var activeIdentityNormalized = false + var reconciliationAttempted = false try { + var legacyRackFieldPresent = false + var legacyRackElement: JsonElement? = null val backup = try { - json.decodeFromString(jsonString) + val rootElement = json.parseToJsonElement(jsonString) + val dataObject = rootElement.jsonObject["data"]?.jsonObject + ?: throw IllegalArgumentException("Backup data object is missing or invalid") + legacyRackFieldPresent = dataObject.containsKey("equipmentRackItems") + legacyRackElement = dataObject["equipmentRackItems"] + json.decodeFromJsonElement(rootElement) } catch (e: Exception) { // Deserialization error — surface a specific message instead of a raw // kotlinx.serialization stack trace. This is the most likely failure mode @@ -331,10 +392,17 @@ abstract class BaseDataBackupManager( // existingPRIds removed — upsertPR handles duplicates via business-key INSERT OR REPLACE val existingCycleIds = queries.selectAllTrainingCyclesSync().executeAsList().map { it.id }.toSet() val existingUserProfileIds = queries.selectAllUserProfileIds().executeAsList().toSet() + preImportActiveProfileId = queries.getAllProfiles() + .executeAsList() + .firstOrNull { it.isActive == 1L } + ?.id + backup.data.userProfiles + .map(UserProfileBackup::id) + .toCollection(representedProfileIds) // Resolve the active profile for adoption of skipped records. // Uses the DB active profile (not the backup's profileId) so legacy backups // with null profileId don't accidentally reassign visible data to "default". - val activeProfileId = queries.getActiveProfile().executeAsOneOrNull()?.id ?: "default" + var activeProfileId = preImportActiveProfileId ?: "default" val importRoutineNameResolutionContext = buildRoutineNameResolutionContextFromBackup( backup.data.routines, backup.data.routineExercises, @@ -396,6 +464,32 @@ abstract class BaseDataBackupManager( // Wrap all imports in a transaction for atomicity database.transaction { + // Identities are the root of every profile-linked restore. Seed the + // preference aggregate in the same transaction and treat backup active + // flags as informational only. + backup.data.userProfiles.distinctBy(UserProfileBackup::id).forEach { profile -> + if (profile.id !in existingUserProfileIds) { + queries.insertUserProfileIgnore( + id = profile.id, + name = profile.name, + colorIndex = profile.colorIndex.toLong(), + createdAt = profile.createdAt, + isActive = 0L, + ) + userProfilesImported++ + } else { + userProfilesSkipped++ + } + // Do not catch this independently: identity + aggregate seeding is + // an invariant of the enclosing transaction. + queries.insertDefaultProfilePreferences(profile.id, 1L) + } + activeProfileId = normalizeImportedActiveIdentity( + preImportActiveProfileId, + representedProfileIds, + ) + val availableProfileIds = existingUserProfileIds + representedProfileIds + // Import workout sessions val importedSessionIds = mutableSetOf() backup.data.workoutSessions.forEach { session -> @@ -466,7 +560,11 @@ abstract class BaseDataBackupManager( dominantSide = session.dominantSide, strengthProfile = session.strengthProfile, formScore = session.formScore, - profile_id = session.profileId ?: "default", + profile_id = resolveImportedProfileId( + requestedProfileId = session.profileId, + activeProfileId = activeProfileId, + availableProfileIds = availableProfileIds, + ), ) } if (inserted != null) { @@ -475,12 +573,16 @@ abstract class BaseDataBackupManager( } } else { // Adopt orphaned records into the active profile (fixes #324). - // Only adopt when the backup row has no explicit profile (legacy) - // or already targets the active profile. If the backup explicitly - // says the row belongs to a different profile, leave it alone to - // avoid cross-contaminating multi-profile restores. - val backupProfileId = session.profileId - if (backupProfileId == null || backupProfileId == activeProfileId) { + // Preserve an explicit owner only when that profile exists locally + // or is represented by an identity in this backup. Session/routine + // exports omit identities, so deleted/source-only owners fall back + // to the active profile instead of leaving invisible history behind. + val resolvedProfileId = resolveImportedProfileId( + requestedProfileId = session.profileId, + activeProfileId = activeProfileId, + availableProfileIds = availableProfileIds, + ) + if (resolvedProfileId == activeProfileId) { queries.adoptSessionProfile(profileId = activeProfileId, id = session.id) sessionsAdopted++ } @@ -531,7 +633,7 @@ abstract class BaseDataBackupManager( createdAt = routine.createdAt, lastUsed = routine.lastUsed, useCount = routine.useCount.toLong(), - profile_id = routine.profileId ?: "default", + profile_id = routine.profileId ?: activeProfileId, groupId = routine.groupId, ) routinesImported++ @@ -707,22 +809,6 @@ abstract class BaseDataBackupManager( } } - // Import user profiles - backup.data.userProfiles.forEach { profile -> - if (profile.id !in existingUserProfileIds) { - queries.insertUserProfileIgnore( - id = profile.id, - name = profile.name, - colorIndex = profile.colorIndex.toLong(), - createdAt = profile.createdAt, - isActive = if (profile.isActive) 1L else 0L, - ) - userProfilesImported++ - } else { - userProfilesSkipped++ - } - } - // Import cycle progress (only for imported cycles) backup.data.cycleProgress.forEach { progress -> if (progress.cycleId in importedCycleIds) { @@ -881,10 +967,28 @@ abstract class BaseDataBackupManager( if (inserted != null) sessionNotesImported++ else sessionNotesSkipped++ } } + databaseWorkCommitted = true + activeIdentityNormalized = true - // Import settings-backed equipment rack items only after the database transaction - // succeeds to preserve atomicity — a DB failure must not leave rack items modified. - importEquipmentRackItems(backup.data.equipmentRackItems) + fun onInvalidProfileState(profileId: String, sectionName: String, failure: Throwable?) { + entitiesWithErrors++ + Logger.w(failure) { + "Import skip profilePreference profileId=$profileId section=$sectionName" + + (failure?.let { " — ${it::class.simpleName}: ${it.message}" } ?: "") + } + } + reconciliationAttempted = true + restoreDeferredAndReconcile( + deferred = DeferredProfileRestore( + backupVersion = backup.version, + profilePreferences = backup.data.profilePreferences, + legacyRackFieldPresent = legacyRackFieldPresent, + legacyRackElement = legacyRackElement, + representedProfileIds = representedProfileIds, + ), + now = KmpUtils.currentTimeMillis(), + onInvalid = ::onInvalidProfileState, + ) if (sessionsAdopted > 0 || routinesAdopted > 0) { Logger.i { "Import adopted $sessionsAdopted session(s) and $routinesAdopted routine(s) into active profile" } @@ -925,7 +1029,29 @@ abstract class BaseDataBackupManager( entitiesWithErrors = entitiesWithErrors, ), ) - } catch (e: Exception) { + } catch (e: Throwable) { + if (databaseWorkCommitted && !reconciliationAttempted) { + withContext(NonCancellable) { + val normalizationFailure = if (activeIdentityNormalized) { + null + } else { + runCatching { + database.transaction { + normalizeImportedActiveIdentity( + preImportActiveProfileId, + representedProfileIds, + ) + } + activeIdentityNormalized = true + }.exceptionOrNull() + } + normalizationFailure?.let(e::addSuppressed) + val reconcileFailure = runCatching { + userProfileRepository.reconcileActiveProfileContext() + }.exceptionOrNull() + reconcileFailure?.let(e::addSuppressed) + } + } // Log the full exception here — not just the bare message — so a user-shared // logcat can identify the failure mode. The caller (SettingsTab) surfaces the // message string; this log line carries the class + stack trace for devs. @@ -951,8 +1077,20 @@ abstract class BaseDataBackupManager( source: BackupStreamSource, onProgress: (BackupProgress) -> Unit = {}, ): Result { + var preImportActiveProfileId: String? = null + val representedProfileIds = linkedSetOf() + val legacySessionIdsForAdoption = mutableListOf() + val legacyRoutineIdsForAdoption = mutableListOf() + val explicitSessionAdoptions = mutableListOf>() + val explicitRoutineAdoptions = mutableListOf>() + var databaseWorkCommitted = false + var activeIdentityNormalized = false + var reconciliationAttempted = false try { val nav = BackupJsonNavigator(source) + val deferredPreferenceEntries = mutableListOf() + var legacyRackFieldPresent = false + var legacyRackElement: JsonElement? = null // -- Track import counts (mirrors importFromJson exactly) -- var sessionsImported = 0 @@ -998,7 +1136,16 @@ abstract class BaseDataBackupManager( val existingSupersetIds = queries.selectAllSupersetIds().executeAsList().toSet() val existingCycleIds = queries.selectAllTrainingCyclesSync().executeAsList().map { it.id }.toSet() val existingUserProfileIds = queries.selectAllUserProfileIds().executeAsList().toSet() - val activeProfileId = queries.getActiveProfile().executeAsOneOrNull()?.id ?: "default" + preImportActiveProfileId = queries.getAllProfiles() + .executeAsList() + .firstOrNull { it.isActive == 1L } + ?.id + val activeProfileId = preImportActiveProfileId ?: "default" + + fun committedTransaction(block: () -> Unit) { + database.transaction { block() } + databaseWorkCommitted = true + } // Error-resilient per-entity insert helper (same pattern as importFromJson) fun tryImport(label: String, entityId: String?, block: () -> T): T? = try { @@ -1042,23 +1189,27 @@ abstract class BaseDataBackupManager( when (fieldName) { // --- equipmentRackItems --- "equipmentRackItems" -> { - val rackItems = mutableListOf() + legacyRackFieldPresent = true + legacyRackElement = json.parseToJsonElement(nav.nextValueAsString()) + } + + // --- profilePreferences (small, deferred to root end) --- + "profilePreferences" -> { nav.beginArray() while (nav.hasNextInArray()) { val rawJson = nav.nextValueAsString() - val item = tryImport("equipmentRackItem-parse", null) { - json.decodeFromString(rawJson) + val entry = tryImport("profilePreference-parse", null) { + json.decodeFromString(rawJson) } ?: continue - rackItems += item + deferredPreferenceEntries += entry } nav.endArray() - importEquipmentRackItems(rackItems) } // --- workoutSessions --- "workoutSessions" -> { onProgress(BackupProgress(BackupPhase.SESSIONS, 0, 0)) - database.transaction { + committedTransaction { nav.beginArray() while (nav.hasNextInArray()) { val rawJson = nav.nextValueAsString() @@ -1127,18 +1278,25 @@ abstract class BaseDataBackupManager( dominantSide = session.dominantSide, strengthProfile = session.strengthProfile, formScore = session.formScore, - profile_id = session.profileId ?: "default", + profile_id = session.profileId ?: activeProfileId, ) } if (inserted != null) { sessionsImported++ importedSessionIds.add(session.id) + if (session.profileId == null) { + legacySessionIdsForAdoption += session.id + } else { + explicitSessionAdoptions += session.id to session.profileId + } } } else { val backupProfileId = session.profileId - if (backupProfileId == null || backupProfileId == activeProfileId) { - queries.adoptSessionProfile(profileId = activeProfileId, id = session.id) + if (backupProfileId == null) { + legacySessionIdsForAdoption += session.id sessionsAdopted++ + } else { + explicitSessionAdoptions += session.id to backupProfileId } sessionsSkipped++ } @@ -1157,7 +1315,7 @@ abstract class BaseDataBackupManager( nav.beginArray() var batchCount = 0 var totalMetricsSeen = 0L - database.transaction { + committedTransaction { while (nav.hasNextInArray()) { val rawJson = nav.nextValueAsString() val metric = tryImport("metric-parse", null) { @@ -1194,7 +1352,7 @@ abstract class BaseDataBackupManager( // Continue batching remaining metrics while (nav.hasNextInArray()) { batchCount = 0 - database.transaction { + committedTransaction { // Process up to IMPORT_BATCH_SIZE metrics per transaction do { val rawJson = nav.nextValueAsString() @@ -1231,7 +1389,7 @@ abstract class BaseDataBackupManager( // --- routines --- "routines" -> { onProgress(BackupProgress(BackupPhase.ROUTINES, 0, 0)) - database.transaction { + committedTransaction { nav.beginArray() while (nav.hasNextInArray()) { val rawJson = nav.nextValueAsString() @@ -1247,16 +1405,21 @@ abstract class BaseDataBackupManager( createdAt = routine.createdAt, lastUsed = routine.lastUsed, useCount = routine.useCount.toLong(), - profile_id = routine.profileId ?: "default", + profile_id = routine.profileId ?: activeProfileId, groupId = routine.groupId, ) routinesImported++ importedRoutineIds.add(routine.id) + if (routine.profileId == null) { + legacyRoutineIdsForAdoption += routine.id + } } else { val backupProfileId = routine.profileId - if (backupProfileId == null || backupProfileId == activeProfileId) { - queries.adoptRoutineProfile(profileId = activeProfileId, id = routine.id) + if (backupProfileId == null) { + legacyRoutineIdsForAdoption += routine.id routinesAdopted++ + } else { + explicitRoutineAdoptions += routine.id to backupProfileId } routinesSkipped++ } @@ -1272,7 +1435,7 @@ abstract class BaseDataBackupManager( // routine group organization on restore. Routine.groupId // has no FK constraint, so import order does not matter. "routineGroups" -> { - database.transaction { + committedTransaction { nav.beginArray() while (nav.hasNextInArray()) { val rawJson = nav.nextValueAsString() @@ -1296,7 +1459,7 @@ abstract class BaseDataBackupManager( // --- supersets --- "supersets" -> { - database.transaction { + committedTransaction { nav.beginArray() while (nav.hasNextInArray()) { val rawJson = nav.nextValueAsString() @@ -1329,7 +1492,7 @@ abstract class BaseDataBackupManager( if (importedRoutineIds.isEmpty()) { Logger.w { "Streaming import: routineExercises encountered but no routines imported yet — exercises for pre-existing routines will be skipped" } } - database.transaction { + committedTransaction { nav.beginArray() while (nav.hasNextInArray()) { val rawJson = nav.nextValueAsString() @@ -1397,7 +1560,7 @@ abstract class BaseDataBackupManager( // --- personalRecords --- "personalRecords" -> { - database.transaction { + committedTransaction { nav.beginArray() while (nav.hasNextInArray()) { val rawJson = nav.nextValueAsString() @@ -1435,7 +1598,7 @@ abstract class BaseDataBackupManager( // --- trainingCycles --- "trainingCycles" -> { - database.transaction { + committedTransaction { nav.beginArray() while (nav.hasNextInArray()) { val rawJson = nav.nextValueAsString() @@ -1469,7 +1632,7 @@ abstract class BaseDataBackupManager( if (importedCycleIds.isEmpty()) { Logger.w { "Streaming import: cycleDays encountered but no cycles imported yet — days for pre-existing cycles will be skipped" } } - database.transaction { + committedTransaction { nav.beginArray() while (nav.hasNextInArray()) { val rawJson = nav.nextValueAsString() @@ -1502,7 +1665,7 @@ abstract class BaseDataBackupManager( // --- userProfiles --- "userProfiles" -> { - database.transaction { + committedTransaction { nav.beginArray() while (nav.hasNextInArray()) { val rawJson = nav.nextValueAsString() @@ -1510,18 +1673,21 @@ abstract class BaseDataBackupManager( json.decodeFromString(rawJson) } ?: continue + if (!representedProfileIds.add(profile.id)) continue + if (profile.id !in existingUserProfileIds) { queries.insertUserProfileIgnore( id = profile.id, name = profile.name, colorIndex = profile.colorIndex.toLong(), createdAt = profile.createdAt, - isActive = if (profile.isActive) 1L else 0L, + isActive = 0L, ) userProfilesImported++ } else { userProfilesSkipped++ } + queries.insertDefaultProfilePreferences(profile.id, 1L) } nav.endArray() } @@ -1529,7 +1695,7 @@ abstract class BaseDataBackupManager( // --- cycleProgress --- "cycleProgress" -> { - database.transaction { + committedTransaction { nav.beginArray() while (nav.hasNextInArray()) { val rawJson = nav.nextValueAsString() @@ -1558,7 +1724,7 @@ abstract class BaseDataBackupManager( // --- cycleProgressions --- "cycleProgressions" -> { - database.transaction { + committedTransaction { nav.beginArray() while (nav.hasNextInArray()) { val rawJson = nav.nextValueAsString() @@ -1583,7 +1749,7 @@ abstract class BaseDataBackupManager( // --- plannedSets --- "plannedSets" -> { - database.transaction { + committedTransaction { nav.beginArray() while (nav.hasNextInArray()) { val rawJson = nav.nextValueAsString() @@ -1611,7 +1777,7 @@ abstract class BaseDataBackupManager( // --- completedSets --- "completedSets" -> { - database.transaction { + committedTransaction { nav.beginArray() while (nav.hasNextInArray()) { val rawJson = nav.nextValueAsString() @@ -1641,7 +1807,7 @@ abstract class BaseDataBackupManager( // --- progressionEvents --- "progressionEvents" -> { - database.transaction { + committedTransaction { nav.beginArray() while (nav.hasNextInArray()) { val rawJson = nav.nextValueAsString() @@ -1668,7 +1834,7 @@ abstract class BaseDataBackupManager( // --- earnedBadges --- "earnedBadges" -> { - database.transaction { + committedTransaction { nav.beginArray() while (nav.hasNextInArray()) { val rawJson = nav.nextValueAsString() @@ -1695,7 +1861,7 @@ abstract class BaseDataBackupManager( // --- streakHistory --- "streakHistory" -> { - database.transaction { + committedTransaction { nav.beginArray() while (nav.hasNextInArray()) { val rawJson = nav.nextValueAsString() @@ -1725,7 +1891,7 @@ abstract class BaseDataBackupManager( json.decodeFromString(rawJson) } if (stats != null) { - database.transaction { + committedTransaction { val stableId = stats.profileId.hashCode().toLong() val inserted = tryImport("gamificationStats", stats.profileId) { queries.upsertGamificationStatsWithSync( @@ -1753,7 +1919,7 @@ abstract class BaseDataBackupManager( // --- sessionNotes --- "sessionNotes" -> { - database.transaction { + committedTransaction { nav.beginArray() while (nav.hasNextInArray()) { val rawJson = nav.nextValueAsString() @@ -1789,6 +1955,60 @@ abstract class BaseDataBackupManager( } nav.endObject() // end root + database.transaction { + val normalizedActiveProfileId = normalizeImportedActiveIdentity( + preImportActiveProfileId, + representedProfileIds, + ) + val availableProfileIds = queries.selectAllUserProfileIds().executeAsList().toSet() + legacySessionIdsForAdoption.forEach { sessionId -> + queries.adoptSessionProfile(normalizedActiveProfileId, sessionId) + } + legacyRoutineIdsForAdoption.forEach { routineId -> + queries.adoptRoutineProfile(normalizedActiveProfileId, routineId) + } + explicitSessionAdoptions.forEach { (sessionId, backupProfileId) -> + if ( + resolveImportedProfileId( + requestedProfileId = backupProfileId, + activeProfileId = normalizedActiveProfileId, + availableProfileIds = availableProfileIds, + ) == normalizedActiveProfileId + ) { + queries.adoptSessionProfile(normalizedActiveProfileId, sessionId) + sessionsAdopted++ + } + } + explicitRoutineAdoptions.forEach { (routineId, backupProfileId) -> + if (backupProfileId == normalizedActiveProfileId) { + queries.adoptRoutineProfile(normalizedActiveProfileId, routineId) + routinesAdopted++ + } + } + } + databaseWorkCommitted = true + activeIdentityNormalized = true + + fun onInvalidProfileState(profileId: String, sectionName: String, failure: Throwable?) { + entitiesWithErrors++ + Logger.w(failure) { + "Streaming import skip profilePreference profileId=$profileId section=$sectionName" + + (failure?.let { " — ${it::class.simpleName}: ${it.message}" } ?: "") + } + } + reconciliationAttempted = true + restoreDeferredAndReconcile( + deferred = DeferredProfileRestore( + backupVersion = backupVersion, + profilePreferences = deferredPreferenceEntries, + legacyRackFieldPresent = legacyRackFieldPresent, + legacyRackElement = legacyRackElement, + representedProfileIds = representedProfileIds, + ), + now = KmpUtils.currentTimeMillis(), + onInvalid = ::onInvalidProfileState, + ) + if (sessionsAdopted > 0 || routinesAdopted > 0) { Logger.i { "Streaming import adopted $sessionsAdopted session(s) and $routinesAdopted routine(s) into active profile" } } @@ -1828,7 +2048,52 @@ abstract class BaseDataBackupManager( entitiesWithErrors = entitiesWithErrors, ), ) - } catch (e: Exception) { + } catch (e: Throwable) { + if (databaseWorkCommitted && !reconciliationAttempted) { + withContext(NonCancellable) { + val normalizationFailure = if (activeIdentityNormalized) { + null + } else { + runCatching { + database.transaction { + val normalizedActiveProfileId = normalizeImportedActiveIdentity( + preImportActiveProfileId, + representedProfileIds, + ) + val availableProfileIds = queries.selectAllUserProfileIds().executeAsList().toSet() + legacySessionIdsForAdoption.forEach { sessionId -> + queries.adoptSessionProfile(normalizedActiveProfileId, sessionId) + } + legacyRoutineIdsForAdoption.forEach { routineId -> + queries.adoptRoutineProfile(normalizedActiveProfileId, routineId) + } + explicitSessionAdoptions.forEach { (sessionId, backupProfileId) -> + if ( + resolveImportedProfileId( + requestedProfileId = backupProfileId, + activeProfileId = normalizedActiveProfileId, + availableProfileIds = availableProfileIds, + ) == normalizedActiveProfileId + ) { + queries.adoptSessionProfile(normalizedActiveProfileId, sessionId) + } + } + explicitRoutineAdoptions.forEach { (routineId, backupProfileId) -> + if (backupProfileId == normalizedActiveProfileId) { + queries.adoptRoutineProfile(normalizedActiveProfileId, routineId) + } + } + } + activeIdentityNormalized = true + }.exceptionOrNull() + } + normalizationFailure?.let(e::addSuppressed) + val reconcileFailure = runCatching { + userProfileRepository.reconcileActiveProfileContext() + }.exceptionOrNull() + reconcileFailure?.let(e::addSuppressed) + } + } Logger.e(e) { "Streaming backup import aborted: ${e::class.simpleName}: ${e.message}" } return Result.failure(e) } @@ -1848,7 +2113,10 @@ abstract class BaseDataBackupManager( val metricCount = runCatching { queries.countBackupMetricSamples().executeAsOne() }.getOrElse { 0L } val routines = queries.selectAllRoutinesSync().executeAsList() val routineExercises = queries.selectAllRoutineExercisesSync().executeAsList() - val equipmentRackItems = equipmentRackRepository.getItems() + val userProfiles = queries.selectAllUserProfilesSync().executeAsList() + val profilePreferences = userProfiles.map { profile -> + profilePreferencesRepository.get(profile.id).toBackup() + } val routineNameResolutionContext = buildRoutineNameResolutionContext(routines, routineExercises) // JSON header @@ -1899,8 +2167,6 @@ abstract class BaseDataBackupManager( writer.write(",") writeJsonArray(writer, "routineExercises", routineExercises.map { json.encodeToString(RoutineExerciseBackup.serializer(), mapRoutineExerciseToBackup(it)) }) writer.write(",") - writeJsonArray(writer, "equipmentRackItems", equipmentRackItems.map { json.encodeToString(RackItem.serializer(), it) }) - writer.write(",") // Phase 5: Remaining tables (small, bulk-load is safe) onProgress(BackupProgress(BackupPhase.OTHER, 0, 0)) @@ -1961,9 +2227,14 @@ abstract class BaseDataBackupManager( } writer.write(",") - val userProfiles = runCatching { queries.selectAllUserProfilesSync().executeAsList() }.getOrElse { emptyList() } writeJsonArray(writer, "userProfiles", userProfiles.map { json.encodeToString(UserProfileBackup.serializer(), mapUserProfileToBackup(it)) }) writer.write(",") + writeJsonArray( + writer, + "profilePreferences", + profilePreferences.map { json.encodeToString(ProfilePreferencesBackup.serializer(), it) }, + ) + writer.write(",") // Session notes (migration 26). Wrap in runCatching so the export still completes // on legacy DBs where the table is missing; v1 backups simply emit an empty array. @@ -2417,19 +2688,209 @@ abstract class BaseDataBackupManager( itemIds.filter { it.isNotBlank() }.distinct(), ) - private suspend fun importEquipmentRackItems(items: List) { - val importedById = items - .filter { it.id.isNotBlank() } - .distinctBy { it.id } - .associateBy { it.id } - if (importedById.isEmpty()) return + private fun normalizeImportedActiveIdentity( + preImportActiveProfileId: String?, + representedProfileIds: Set, + ): String { + val existingProfileIds = queries.selectAllUserProfileIds().executeAsList().toSet() + val activeProfileId = preImportActiveProfileId + ?.takeIf { it in existingProfileIds } + ?: "default".takeIf { it in existingProfileIds } + ?: representedProfileIds.firstOrNull { it in existingProfileIds } + ?: error("No usable profile identity after backup import") + queries.setActiveProfile(activeProfileId) + return activeProfileId + } - val existing = equipmentRackRepository.getItems() - val existingIds = existing.map { it.id }.toSet() - val merged = existing.map { item -> importedById[item.id] ?: item } + - importedById.values.filterNot { it.id in existingIds } + private fun resolveImportedProfileId( + requestedProfileId: String?, + activeProfileId: String, + availableProfileIds: Set, + ): String = requestedProfileId?.takeIf { it in availableProfileIds } ?: activeProfileId + + private inline fun decodeBackupSection( + profileId: String, + sectionName: String, + element: JsonElement, + validate: (T) -> List, + onInvalid: (String, String, Throwable?) -> Unit, + ): T? = runCatching { + val value = json.decodeFromJsonElement(element.jsonObject) + val validationErrors = validate(value) + require(validationErrors.isEmpty()) { validationErrors.joinToString(",") } + value + }.fold( + onSuccess = { it }, + onFailure = { + onInvalid(profileId, sectionName, it) + null + }, + ) - equipmentRackRepository.saveItems(merged) + private fun decodeLegacyV4Rack( + element: JsonElement?, + onInvalid: (String, String, Throwable?) -> Unit, + ): List? { + if (element == null) { + onInvalid("backup", "equipmentRackItems", null) + return null + } + return runCatching { + json.decodeFromJsonElement>(element) + }.fold( + onSuccess = { it }, + onFailure = { + onInvalid("backup", "equipmentRackItems", it) + null + }, + ) + } + + private suspend fun restoreDeferredProfileState( + deferred: DeferredProfileRestore, + now: Long, + onInvalid: (String, String, Throwable?) -> Unit, + ) { + val profileIdsAfterImport = queries.selectAllUserProfileIds().executeAsList().toSet() + val eligibleProfileIds = deferred.representedProfileIds + .filterTo(linkedSetOf()) { it in profileIdsAfterImport } + + when { + deferred.backupVersion < 4 -> Unit + deferred.backupVersion == 4 && !deferred.legacyRackFieldPresent -> Unit + deferred.backupVersion == 4 -> restoreLegacyV4Rack( + items = decodeLegacyV4Rack(deferred.legacyRackElement, onInvalid), + eligibleProfileIds = eligibleProfileIds, + profileIdsAfterImport = profileIdsAfterImport, + now = now, + onInvalid = onInvalid, + ) + else -> restoreV5ProfilePreferences( + entries = deferred.profilePreferences, + eligibleProfileIds = eligibleProfileIds, + now = now, + onInvalid = onInvalid, + ) + } + } + + private suspend fun restoreLegacyV4Rack( + items: List?, + eligibleProfileIds: Set, + profileIdsAfterImport: Set, + now: Long, + onInvalid: (String, String, Throwable?) -> Unit, + ) { + if (items == null) return + val targetProfileIds = eligibleProfileIds.ifEmpty { + listOfNotNull( + queries.getActiveProfile() + .executeAsOneOrNull() + ?.id + ?.takeIf { it in profileIdsAfterImport }, + ) + } + val imported = items.filter { it.id.isNotBlank() }.distinctBy(RackItem::id) + val importedById = imported.associateBy(RackItem::id) + targetProfileIds.forEach { profileId -> + val existing = profilePreferencesRepository.get(profileId).rack.value.items + val mergedItems = if (items.isEmpty()) { + emptyList() + } else { + val existingIds = existing.mapTo(linkedSetOf(), RackItem::id) + existing.map { item -> importedById[item.id] ?: item } + + imported.filterNot { it.id in existingIds } + } + val candidate = RackPreferences(items = mergedItems) + val validationErrors = ProfilePreferencesValidator.rack(candidate) + if (validationErrors.isNotEmpty()) { + onInvalid( + profileId, + "equipmentRackItems", + IllegalArgumentException(validationErrors.joinToString(",")), + ) + } else { + profilePreferencesRepository.updateRack(profileId, candidate, now) + } + } + } + + private suspend fun restoreV5ProfilePreferences( + entries: List, + eligibleProfileIds: Set, + now: Long, + onInvalid: (String, String, Throwable?) -> Unit, + ) { + entries.forEach { entry -> + if (entry.profileId !in eligibleProfileIds) return@forEach + // Infrastructure failures from this read are intentionally fatal and must + // never be reclassified as malformed backup input. + profilePreferencesRepository.get(entry.profileId) + entry.core?.let { element -> + decodeBackupSection( + entry.profileId, + "core", + element, + ProfilePreferencesValidator::core, + onInvalid, + )?.let { profilePreferencesRepository.updateCore(entry.profileId, it, now) } + } + entry.rack?.let { element -> + decodeBackupSection( + entry.profileId, + "rack", + element, + ProfilePreferencesValidator::rack, + onInvalid, + )?.let { profilePreferencesRepository.updateRack(entry.profileId, it, now) } + } + entry.workout?.let { element -> + decodeBackupSection( + entry.profileId, + "workout", + element, + ProfilePreferencesValidator::workout, + onInvalid, + )?.let { profilePreferencesRepository.updateWorkout(entry.profileId, it, now) } + } + entry.led?.let { element -> + decodeBackupSection( + entry.profileId, + "led", + element, + ProfilePreferencesValidator::led, + onInvalid, + )?.let { profilePreferencesRepository.updateLed(entry.profileId, it, now) } + } + entry.vbt?.let { element -> + decodeBackupSection( + entry.profileId, + "vbt", + element, + ProfilePreferencesValidator::vbt, + onInvalid, + )?.let { profilePreferencesRepository.updateVbt(entry.profileId, it, now) } + } + } + } + + private suspend fun restoreDeferredAndReconcile( + deferred: DeferredProfileRestore, + now: Long, + onInvalid: (String, String, Throwable?) -> Unit, + ) { + try { + restoreDeferredProfileState(deferred, now, onInvalid) + } catch (restoreFailure: Throwable) { + val reconcileFailure = runCatching { + withContext(NonCancellable) { + userProfileRepository.reconcileActiveProfileContext() + } + }.exceptionOrNull() + reconcileFailure?.let(restoreFailure::addSuppressed) + throw restoreFailure + } + userProfileRepository.reconcileActiveProfileContext() } private fun mapSupersetToBackup(superset: Superset): SupersetBackup = SupersetBackup( diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/KmpUtils.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/KmpUtils.kt index 51f435561..53c83560c 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/KmpUtils.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/KmpUtils.kt @@ -74,6 +74,13 @@ object KmpUtils { val dateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault()) return when (pattern) { + "MMM d" -> { + val monthName = dateTime.month.name.take(3).lowercase().replaceFirstChar { + it.uppercase() + } + "$monthName ${dateTime.day}" + } + "MMM dd, yyyy" -> { val monthName = dateTime.month.name.take(3).lowercase().replaceFirstChar { it.uppercase() diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/RackItemJson.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/RackItemJson.kt new file mode 100644 index 000000000..096b46d98 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/RackItemJson.kt @@ -0,0 +1,9 @@ +package com.devil.phoenixproject.util + +import com.devil.phoenixproject.domain.model.RackItem +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +private val rackItemsJson = Json { encodeDefaults = true } + +fun encodeRackItemsJson(items: List): String = rackItemsJson.encodeToString(items) diff --git a/shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/VitruvianDatabase.sq b/shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/VitruvianDatabase.sq index 7c1c160a4..2167097e8 100644 --- a/shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/VitruvianDatabase.sq +++ b/shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/VitruvianDatabase.sq @@ -506,6 +506,9 @@ countVelocityOneRepMaxByExercise: SELECT COUNT(*) FROM VelocityOneRepMaxEstimate WHERE exerciseId = :exerciseId AND profile_id = :profileId AND deletedAt IS NULL; +deleteVelocityOneRepMaxById: +DELETE FROM VelocityOneRepMaxEstimate WHERE id = ?; + -- ==================== PERSONAL MVT TABLE ==================== -- Personalized Minimum Velocity Threshold per exercise/profile (issue #517). -- Stored alongside the exercise; used by VelocityOneRepMaxEstimator to override @@ -528,6 +531,23 @@ upsertExerciseMvt: INSERT OR REPLACE INTO ExerciseMvt (exerciseId, profile_id, personalMvtMs, sampleCount, updatedAt) VALUES (:exerciseId, :profileId, :personalMvtMs, :sampleCount, :updatedAt); +selectAllExerciseMvtByProfile: +SELECT * FROM ExerciseMvt WHERE profile_id = :profileId ORDER BY exerciseId; + +deleteExerciseMvt: +DELETE FROM ExerciseMvt WHERE exerciseId = :exerciseId AND profile_id = :profileId; + +updateExerciseMvtForProfileMerge: +UPDATE ExerciseMvt +SET profile_id = :targetProfileId, + personalMvtMs = :personalMvtMs, + sampleCount = :sampleCount, + updatedAt = :updatedAt +WHERE exerciseId = :exerciseId AND profile_id = :retainedProfileId; + +reassignExerciseMvtProfile: +UPDATE ExerciseMvt SET profile_id = :targetProfileId WHERE profile_id = :sourceProfileId; + -- Queries -- Exercise Queries @@ -677,6 +697,25 @@ AND deletedAt IS NULL AND (workingReps > 0 OR totalReps > 0) ORDER BY timestamp DESC; +selectRecentCompletedSessionsForExercise: +SELECT * FROM WorkoutSession +WHERE profile_id = :profileId + AND exerciseId = :exerciseId + AND deletedAt IS NULL + AND (workingReps > 0 OR totalReps > 0) +ORDER BY timestamp DESC, id DESC +LIMIT :limit; + +selectMostRecentCompletedExerciseId: +SELECT exerciseId FROM WorkoutSession +WHERE profile_id = :profileId + AND exerciseId IS NOT NULL + AND TRIM(exerciseId) != '' + AND deletedAt IS NULL + AND (workingReps > 0 OR totalReps > 0) +ORDER BY timestamp DESC, id DESC +LIMIT 1; + selectSessionById: SELECT * FROM WorkoutSession WHERE id = ?; @@ -1988,6 +2027,12 @@ DELETE FROM ProgressionEvent WHERE id = ?; updateOneRepMax: UPDATE Exercise SET one_rep_max_kg = ? WHERE id = ?; +restoreOneRepMaxIfCurrent: +UPDATE Exercise +SET one_rep_max_kg = :previousOneRepMaxKg +WHERE id = :exerciseId + AND one_rep_max_kg = :attemptedOneRepMaxKg; + getExercisesWithOneRepMax: SELECT * FROM Exercise WHERE one_rep_max_kg IS NOT NULL; @@ -2121,6 +2166,324 @@ CREATE TABLE UserProfile ( last_auth_at INTEGER ); +CREATE TABLE UserProfilePreferences ( + profile_id TEXT PRIMARY KEY NOT NULL, + schema_version INTEGER NOT NULL DEFAULT 1, + legacy_migration_version INTEGER NOT NULL DEFAULT 0, + body_weight_kg REAL NOT NULL DEFAULT 0 CHECK(body_weight_kg = 0 OR body_weight_kg BETWEEN 20 AND 300), + weight_unit TEXT NOT NULL DEFAULT 'LB' CHECK(weight_unit IN ('KG', 'LB')), + weight_increment REAL NOT NULL DEFAULT -1 CHECK(weight_increment = -1 OR weight_increment > 0), + core_updated_at INTEGER NOT NULL DEFAULT 0, + core_local_generation INTEGER NOT NULL DEFAULT 0 CHECK(core_local_generation >= 0), + core_server_revision INTEGER NOT NULL DEFAULT 0 CHECK(core_server_revision >= 0), + core_dirty INTEGER NOT NULL DEFAULT 1 CHECK(core_dirty IN (0, 1)), + equipment_rack_json TEXT NOT NULL DEFAULT '{"version":1,"items":[]}', + rack_updated_at INTEGER NOT NULL DEFAULT 0, + rack_local_generation INTEGER NOT NULL DEFAULT 0 CHECK(rack_local_generation >= 0), + rack_server_revision INTEGER NOT NULL DEFAULT 0 CHECK(rack_server_revision >= 0), + rack_dirty INTEGER NOT NULL DEFAULT 1 CHECK(rack_dirty IN (0, 1)), + workout_preferences_json TEXT NOT NULL DEFAULT '{"version":1}', + workout_updated_at INTEGER NOT NULL DEFAULT 0, + workout_local_generation INTEGER NOT NULL DEFAULT 0 CHECK(workout_local_generation >= 0), + workout_server_revision INTEGER NOT NULL DEFAULT 0 CHECK(workout_server_revision >= 0), + workout_dirty INTEGER NOT NULL DEFAULT 1 CHECK(workout_dirty IN (0, 1)), + led_color_scheme_id INTEGER NOT NULL DEFAULT 0 CHECK(led_color_scheme_id >= 0), + led_preferences_json TEXT NOT NULL DEFAULT '{"version":1}', + led_updated_at INTEGER NOT NULL DEFAULT 0, + led_local_generation INTEGER NOT NULL DEFAULT 0 CHECK(led_local_generation >= 0), + led_server_revision INTEGER NOT NULL DEFAULT 0 CHECK(led_server_revision >= 0), + led_dirty INTEGER NOT NULL DEFAULT 1 CHECK(led_dirty IN (0, 1)), + vbt_enabled INTEGER NOT NULL DEFAULT 1 CHECK(vbt_enabled IN (0, 1)), + vbt_preferences_json TEXT NOT NULL DEFAULT '{"version":1}', + vbt_updated_at INTEGER NOT NULL DEFAULT 0, + vbt_local_generation INTEGER NOT NULL DEFAULT 0 CHECK(vbt_local_generation >= 0), + vbt_server_revision INTEGER NOT NULL DEFAULT 0 CHECK(vbt_server_revision >= 0), + vbt_dirty INTEGER NOT NULL DEFAULT 1 CHECK(vbt_dirty IN (0, 1)), + FOREIGN KEY (profile_id) REFERENCES UserProfile(id) ON DELETE CASCADE +); + +CREATE TABLE PendingProfileLocalCleanup ( + profile_id TEXT PRIMARY KEY NOT NULL, + enqueued_at INTEGER NOT NULL +); + +CREATE TABLE PendingProfileContextRecovery ( + recovery_key TEXT PRIMARY KEY NOT NULL + DEFAULT 'active_profile_transition' + CHECK(recovery_key = 'active_profile_transition'), + prior_profile_id TEXT NOT NULL, + created_profile_id TEXT, + enqueued_at INTEGER NOT NULL +); + +selectProfilePreferences: +SELECT * FROM UserProfilePreferences WHERE profile_id = ?; + +selectAllProfilePreferences: +SELECT * FROM UserProfilePreferences ORDER BY profile_id; + +insertDefaultProfilePreferences: +INSERT OR IGNORE INTO UserProfilePreferences(profile_id, legacy_migration_version) +VALUES (?, ?); + +seedMissingProfilePreferences: +INSERT OR IGNORE INTO UserProfilePreferences(profile_id) +SELECT id FROM UserProfile; + +updateCoreProfilePreferences: +UPDATE UserProfilePreferences +SET body_weight_kg = ?, weight_unit = ?, weight_increment = ?, core_updated_at = ?, + core_local_generation = core_local_generation + 1, core_dirty = 1 +WHERE profile_id = ?; + +updateRackProfilePreferences: +UPDATE UserProfilePreferences +SET equipment_rack_json = ?, rack_updated_at = ?, rack_local_generation = rack_local_generation + 1, rack_dirty = 1 +WHERE profile_id = ?; + +updateWorkoutProfilePreferences: +UPDATE UserProfilePreferences +SET workout_preferences_json = ?, workout_updated_at = ?, workout_local_generation = workout_local_generation + 1, workout_dirty = 1 +WHERE profile_id = ?; + +updateLedProfilePreferences: +UPDATE UserProfilePreferences +SET led_color_scheme_id = ?, led_preferences_json = ?, led_updated_at = ?, + led_local_generation = led_local_generation + 1, led_dirty = 1 +WHERE profile_id = ?; + +updateVbtProfilePreferences: +UPDATE UserProfilePreferences +SET vbt_enabled = ?, vbt_preferences_json = ?, vbt_updated_at = ?, + vbt_local_generation = vbt_local_generation + 1, vbt_dirty = 1 +WHERE profile_id = ?; + +selectDirtyProfilePreferenceRows: +SELECT * +FROM UserProfilePreferences +WHERE core_dirty = 1 + OR rack_dirty = 1 + OR workout_dirty = 1 + OR led_dirty = 1 + OR vbt_dirty = 1 +ORDER BY profile_id; + +selectProfilePreferenceSyncRow: +SELECT * +FROM UserProfilePreferences +WHERE profile_id = :profile_id; + +selectChangedRowCount: +SELECT changes(); + +applyCoreCanonicalForGeneration: +UPDATE UserProfilePreferences +SET body_weight_kg = :body_weight_kg, + weight_unit = :weight_unit, + weight_increment = :weight_increment, + core_updated_at = :server_updated_at, + core_server_revision = :server_revision, + core_dirty = 0 +WHERE profile_id = :profile_id + AND core_local_generation = :sent_local_generation + AND core_dirty = 1 + AND core_server_revision <= :server_revision; + +advanceCoreRevisionForNewerGeneration: +UPDATE UserProfilePreferences +SET core_server_revision = :server_revision +WHERE profile_id = :profile_id + AND core_local_generation > :sent_local_generation + AND core_server_revision < :server_revision; + +applyRackCanonicalForGeneration: +UPDATE UserProfilePreferences +SET equipment_rack_json = :equipment_rack_json, + rack_updated_at = :server_updated_at, + rack_server_revision = :server_revision, + rack_dirty = 0 +WHERE profile_id = :profile_id + AND rack_local_generation = :sent_local_generation + AND rack_dirty = 1 + AND rack_server_revision <= :server_revision; + +advanceRackRevisionForNewerGeneration: +UPDATE UserProfilePreferences +SET rack_server_revision = :server_revision +WHERE profile_id = :profile_id + AND rack_local_generation > :sent_local_generation + AND rack_server_revision < :server_revision; + +applyWorkoutCanonicalForGeneration: +UPDATE UserProfilePreferences +SET workout_preferences_json = :workout_preferences_json, + workout_updated_at = :server_updated_at, + workout_server_revision = :server_revision, + workout_dirty = 0 +WHERE profile_id = :profile_id + AND workout_local_generation = :sent_local_generation + AND workout_dirty = 1 + AND workout_server_revision <= :server_revision; + +advanceWorkoutRevisionForNewerGeneration: +UPDATE UserProfilePreferences +SET workout_server_revision = :server_revision +WHERE profile_id = :profile_id + AND workout_local_generation > :sent_local_generation + AND workout_server_revision < :server_revision; + +applyLedCanonicalForGeneration: +UPDATE UserProfilePreferences +SET led_color_scheme_id = :led_color_scheme_id, + led_preferences_json = :led_preferences_json, + led_updated_at = :server_updated_at, + led_server_revision = :server_revision, + led_dirty = 0 +WHERE profile_id = :profile_id + AND led_local_generation = :sent_local_generation + AND led_dirty = 1 + AND led_server_revision <= :server_revision; + +advanceLedRevisionForNewerGeneration: +UPDATE UserProfilePreferences +SET led_server_revision = :server_revision +WHERE profile_id = :profile_id + AND led_local_generation > :sent_local_generation + AND led_server_revision < :server_revision; + +applyVbtCanonicalForGeneration: +UPDATE UserProfilePreferences +SET vbt_enabled = :vbt_enabled, + vbt_preferences_json = :vbt_preferences_json, + vbt_updated_at = :server_updated_at, + vbt_server_revision = :server_revision, + vbt_dirty = 0 +WHERE profile_id = :profile_id + AND vbt_local_generation = :sent_local_generation + AND vbt_dirty = 1 + AND vbt_server_revision <= :server_revision; + +advanceVbtRevisionForNewerGeneration: +UPDATE UserProfilePreferences +SET vbt_server_revision = :server_revision +WHERE profile_id = :profile_id + AND vbt_local_generation > :sent_local_generation + AND vbt_server_revision < :server_revision; + +applyPulledCoreWhenClean: +UPDATE UserProfilePreferences +SET body_weight_kg = :body_weight_kg, + weight_unit = :weight_unit, + weight_increment = :weight_increment, + core_updated_at = :server_updated_at, + core_server_revision = :server_revision, + core_dirty = 0 +WHERE profile_id = :profile_id + AND core_dirty = 0 + AND core_server_revision <= :server_revision; + +applyPulledRackWhenClean: +UPDATE UserProfilePreferences +SET equipment_rack_json = :equipment_rack_json, + rack_updated_at = :server_updated_at, + rack_server_revision = :server_revision, + rack_dirty = 0 +WHERE profile_id = :profile_id + AND rack_dirty = 0 + AND rack_server_revision <= :server_revision; + +applyPulledWorkoutWhenClean: +UPDATE UserProfilePreferences +SET workout_preferences_json = :workout_preferences_json, + workout_updated_at = :server_updated_at, + workout_server_revision = :server_revision, + workout_dirty = 0 +WHERE profile_id = :profile_id + AND workout_dirty = 0 + AND workout_server_revision <= :server_revision; + +applyPulledLedWhenClean: +UPDATE UserProfilePreferences +SET led_color_scheme_id = :led_color_scheme_id, + led_preferences_json = :led_preferences_json, + led_updated_at = :server_updated_at, + led_server_revision = :server_revision, + led_dirty = 0 +WHERE profile_id = :profile_id + AND led_dirty = 0 + AND led_server_revision <= :server_revision; + +applyPulledVbtWhenClean: +UPDATE UserProfilePreferences +SET vbt_enabled = :vbt_enabled, + vbt_preferences_json = :vbt_preferences_json, + vbt_updated_at = :server_updated_at, + vbt_server_revision = :server_revision, + vbt_dirty = 0 +WHERE profile_id = :profile_id + AND vbt_dirty = 0 + AND vbt_server_revision <= :server_revision; + +applyLegacyProfilePreferences: +UPDATE UserProfilePreferences +SET schema_version = 1, + legacy_migration_version = 1, + body_weight_kg = :body_weight_kg, + weight_unit = :weight_unit, + weight_increment = :weight_increment, + core_updated_at = :migrated_at, + core_local_generation = 1, + core_server_revision = 0, + core_dirty = 1, + equipment_rack_json = :equipment_rack_json, + rack_updated_at = :migrated_at, + rack_local_generation = 1, + rack_server_revision = 0, + rack_dirty = 1, + workout_preferences_json = :workout_preferences_json, + workout_updated_at = :migrated_at, + workout_local_generation = 1, + workout_server_revision = 0, + workout_dirty = 1, + led_color_scheme_id = :led_color_scheme_id, + led_preferences_json = :led_preferences_json, + led_updated_at = :migrated_at, + led_local_generation = 1, + led_server_revision = 0, + led_dirty = 1, + vbt_enabled = :vbt_enabled, + vbt_preferences_json = :vbt_preferences_json, + vbt_updated_at = :migrated_at, + vbt_local_generation = 1, + vbt_server_revision = 0, + vbt_dirty = 1 +WHERE profile_id = :profile_id AND legacy_migration_version = 0; + +deleteProfilePreferences: +DELETE FROM UserProfilePreferences WHERE profile_id = ?; + +enqueueProfileLocalCleanup: +INSERT OR REPLACE INTO PendingProfileLocalCleanup(profile_id, enqueued_at) VALUES (?, ?); + +selectPendingProfileLocalCleanup: +SELECT * FROM PendingProfileLocalCleanup ORDER BY enqueued_at, profile_id; + +dequeueProfileLocalCleanup: +DELETE FROM PendingProfileLocalCleanup WHERE profile_id = ?; + +enqueueProfileContextRecovery: +INSERT OR REPLACE INTO PendingProfileContextRecovery( + recovery_key, prior_profile_id, created_profile_id, enqueued_at +) VALUES ('active_profile_transition', ?, ?, ?); + +selectPendingProfileContextRecovery: +SELECT * FROM PendingProfileContextRecovery +WHERE recovery_key = 'active_profile_transition'; + +clearPendingProfileContextRecovery: +DELETE FROM PendingProfileContextRecovery +WHERE recovery_key = 'active_profile_transition'; + -- Queries for UserProfile getActiveProfile: SELECT * FROM UserProfile WHERE isActive = 1 LIMIT 1; @@ -2145,6 +2508,9 @@ UPDATE UserProfile SET isActive = CASE WHEN id = ? THEN 1 ELSE 0 END; reassignRoutineProfile: UPDATE Routine SET profile_id = ? WHERE profile_id = ?; +reassignRoutineGroupProfile: +UPDATE RoutineGroup SET profile_id = ? WHERE profile_id = ?; + reassignSessionProfile: UPDATE WorkoutSession SET profile_id = ? WHERE profile_id = ?; @@ -2175,9 +2541,52 @@ DELETE FROM RpgAttributes WHERE profile_id = ?; reassignAssessmentResultProfile: UPDATE AssessmentResult SET profile_id = ? WHERE profile_id = ?; +reassignVelocityOneRepMaxProfile: +UPDATE VelocityOneRepMaxEstimate SET profile_id = ? WHERE profile_id = ?; + reassignProgressionProfile: UPDATE ProgressionEvent SET profile_id = ? WHERE profile_id = ?; +deletePersonalRecordById: +DELETE FROM PersonalRecord WHERE id = ?; + +updatePersonalRecordForProfileMerge: +UPDATE PersonalRecord +SET exerciseName = :exerciseName, + weight = :weight, + reps = :reps, + oneRepMax = :oneRepMax, + achievedAt = :achievedAt, + workoutMode = :workoutMode, + prType = :prType, + volume = :volume, + phase = :phase, + updatedAt = :updatedAt, + serverId = :serverId, + deletedAt = :deletedAt, + cable_count = :cableCount, + uuid = :uuid, + profile_id = :targetProfileId +WHERE id = :targetId; + +deleteEarnedBadgeById: +DELETE FROM EarnedBadge WHERE id = ?; + +updateEarnedBadgeForProfileMerge: +UPDATE EarnedBadge +SET earnedAt = :earnedAt, + celebratedAt = :celebratedAt, + updatedAt = :updatedAt, + serverId = :serverId, + deletedAt = :deletedAt +WHERE id = :targetId; + +deleteIntegrationStatusByProfile: +DELETE FROM IntegrationStatus WHERE profileId = ?; + +deleteIntegrationSyncCursorByProfile: +DELETE FROM IntegrationSyncCursor WHERE profileId = ?; + deleteProfile: DELETE FROM UserProfile WHERE id = ? AND id != 'default'; @@ -2638,6 +3047,19 @@ WHERE externalId = ? AND provider = ? AND profileId = ?; deleteExternalActivitiesByProvider: DELETE FROM ExternalActivity WHERE provider = ? AND profileId = ?; +deleteConflictingSourceExternalActivities: +DELETE FROM ExternalActivity +WHERE profileId = :sourceProfileId + AND EXISTS ( + SELECT 1 FROM ExternalActivity target + WHERE target.profileId = :targetProfileId + AND target.provider = ExternalActivity.provider + AND target.externalId = ExternalActivity.externalId + ); + +reassignExternalActivityProfile: +UPDATE ExternalActivity SET profileId = :targetProfileId WHERE profileId = :sourceProfileId; + -- ExternalRoutine queries getExternalRoutines: SELECT * FROM ExternalRoutine @@ -2729,6 +3151,47 @@ WHERE externalRoutineId IN ( deleteExternalRoutinesByProvider: DELETE FROM ExternalRoutine WHERE provider = ? AND profileId = ?; +deleteConflictingSourceExternalRoutineSets: +DELETE FROM ExternalRoutineSet +WHERE externalRoutineExerciseId IN ( + SELECT exercise.id + FROM ExternalRoutineExercise exercise + JOIN ExternalRoutine source ON source.id = exercise.externalRoutineId + WHERE source.profileId = :sourceProfileId + AND EXISTS ( + SELECT 1 FROM ExternalRoutine target + WHERE target.profileId = :targetProfileId + AND target.provider = source.provider + AND target.externalId = source.externalId + ) +); + +deleteConflictingSourceExternalRoutineExercises: +DELETE FROM ExternalRoutineExercise +WHERE externalRoutineId IN ( + SELECT source.id FROM ExternalRoutine source + WHERE source.profileId = :sourceProfileId + AND EXISTS ( + SELECT 1 FROM ExternalRoutine target + WHERE target.profileId = :targetProfileId + AND target.provider = source.provider + AND target.externalId = source.externalId + ) +); + +deleteConflictingSourceExternalRoutines: +DELETE FROM ExternalRoutine +WHERE profileId = :sourceProfileId + AND EXISTS ( + SELECT 1 FROM ExternalRoutine target + WHERE target.profileId = :targetProfileId + AND target.provider = ExternalRoutine.provider + AND target.externalId = ExternalRoutine.externalId + ); + +reassignExternalRoutineProfile: +UPDATE ExternalRoutine SET profileId = :targetProfileId WHERE profileId = :sourceProfileId; + -- ExternalRoutineFolder queries getExternalRoutineFolders: SELECT * FROM ExternalRoutineFolder @@ -2748,6 +3211,19 @@ INSERT OR REPLACE INTO ExternalRoutineFolder( deleteExternalRoutineFoldersByProvider: DELETE FROM ExternalRoutineFolder WHERE provider = ? AND profileId = ?; +deleteConflictingSourceExternalRoutineFolders: +DELETE FROM ExternalRoutineFolder +WHERE profileId = :sourceProfileId + AND EXISTS ( + SELECT 1 FROM ExternalRoutineFolder target + WHERE target.profileId = :targetProfileId + AND target.provider = ExternalRoutineFolder.provider + AND target.externalId = ExternalRoutineFolder.externalId + ); + +reassignExternalRoutineFolderProfile: +UPDATE ExternalRoutineFolder SET profileId = :targetProfileId WHERE profileId = :sourceProfileId; + -- ExternalProgram queries getExternalPrograms: SELECT * FROM ExternalProgram @@ -2801,6 +3277,32 @@ WHERE id = ?; deleteExternalProgramsByProvider: DELETE FROM ExternalProgram WHERE provider = ? AND profileId = ?; +deleteConflictingSourceExternalProgramStats: +DELETE FROM ExternalProgramStats +WHERE externalProgramId IN ( + SELECT source.id FROM ExternalProgram source + WHERE source.profileId = :sourceProfileId + AND EXISTS ( + SELECT 1 FROM ExternalProgram target + WHERE target.profileId = :targetProfileId + AND target.provider = source.provider + AND target.externalId = source.externalId + ) +); + +deleteConflictingSourceExternalPrograms: +DELETE FROM ExternalProgram +WHERE profileId = :sourceProfileId + AND EXISTS ( + SELECT 1 FROM ExternalProgram target + WHERE target.profileId = :targetProfileId + AND target.provider = ExternalProgram.provider + AND target.externalId = ExternalProgram.externalId + ); + +reassignExternalProgramProfile: +UPDATE ExternalProgram SET profileId = :targetProfileId WHERE profileId = :sourceProfileId; + -- ExternalProgramStats queries getExternalProgramStats: SELECT * FROM ExternalProgramStats WHERE externalProgramId = ?; @@ -2862,6 +3364,19 @@ GROUP BY provider; deleteExternalExerciseTemplatesByProvider: DELETE FROM ExternalExerciseTemplate WHERE provider = ? AND profileId = ?; +deleteConflictingSourceExternalExerciseTemplates: +DELETE FROM ExternalExerciseTemplate +WHERE profileId = :sourceProfileId + AND EXISTS ( + SELECT 1 FROM ExternalExerciseTemplate target + WHERE target.profileId = :targetProfileId + AND target.provider = ExternalExerciseTemplate.provider + AND target.externalId = ExternalExerciseTemplate.externalId + ); + +reassignExternalExerciseTemplateProfile: +UPDATE ExternalExerciseTemplate SET profileId = :targetProfileId WHERE profileId = :sourceProfileId; + -- ExternalExerciseTemplateMapping queries getExternalExerciseTemplateMapping: SELECT * FROM ExternalExerciseTemplateMapping @@ -2875,6 +3390,19 @@ INSERT OR REPLACE INTO ExternalExerciseTemplateMapping( deleteExternalExerciseTemplateMappingsByProvider: DELETE FROM ExternalExerciseTemplateMapping WHERE provider = ? AND profileId = ?; +deleteConflictingSourceExternalExerciseTemplateMappings: +DELETE FROM ExternalExerciseTemplateMapping +WHERE profileId = :sourceProfileId + AND EXISTS ( + SELECT 1 FROM ExternalExerciseTemplateMapping target + WHERE target.profileId = :targetProfileId + AND target.provider = ExternalExerciseTemplateMapping.provider + AND target.externalTemplateId = ExternalExerciseTemplateMapping.externalTemplateId + ); + +reassignExternalExerciseTemplateMappingProfile: +UPDATE ExternalExerciseTemplateMapping SET profileId = :targetProfileId WHERE profileId = :sourceProfileId; + -- ExternalBodyMeasurement queries getExternalBodyMeasurements: SELECT * FROM ExternalBodyMeasurement @@ -2899,6 +3427,19 @@ INSERT OR REPLACE INTO ExternalBodyMeasurement( deleteExternalBodyMeasurementsByProvider: DELETE FROM ExternalBodyMeasurement WHERE provider = ? AND profileId = ?; +deleteConflictingSourceExternalBodyMeasurements: +DELETE FROM ExternalBodyMeasurement +WHERE profileId = :sourceProfileId + AND EXISTS ( + SELECT 1 FROM ExternalBodyMeasurement target + WHERE target.profileId = :targetProfileId + AND target.provider = ExternalBodyMeasurement.provider + AND target.externalId = ExternalBodyMeasurement.externalId + ); + +reassignExternalBodyMeasurementProfile: +UPDATE ExternalBodyMeasurement SET profileId = :targetProfileId WHERE profileId = :sourceProfileId; + -- IntegrationSyncCursor queries getIntegrationSyncCursor: SELECT * FROM IntegrationSyncCursor diff --git a/shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/migrations/42.sqm b/shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/migrations/42.sqm new file mode 100644 index 000000000..60daefb85 --- /dev/null +++ b/shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/migrations/42.sqm @@ -0,0 +1,52 @@ +CREATE TABLE UserProfilePreferences ( + profile_id TEXT PRIMARY KEY NOT NULL, + schema_version INTEGER NOT NULL DEFAULT 1, + legacy_migration_version INTEGER NOT NULL DEFAULT 0, + body_weight_kg REAL NOT NULL DEFAULT 0 CHECK(body_weight_kg = 0 OR body_weight_kg BETWEEN 20 AND 300), + weight_unit TEXT NOT NULL DEFAULT 'LB' CHECK(weight_unit IN ('KG', 'LB')), + weight_increment REAL NOT NULL DEFAULT -1 CHECK(weight_increment = -1 OR weight_increment > 0), + core_updated_at INTEGER NOT NULL DEFAULT 0, + core_local_generation INTEGER NOT NULL DEFAULT 0 CHECK(core_local_generation >= 0), + core_server_revision INTEGER NOT NULL DEFAULT 0 CHECK(core_server_revision >= 0), + core_dirty INTEGER NOT NULL DEFAULT 1 CHECK(core_dirty IN (0, 1)), + equipment_rack_json TEXT NOT NULL DEFAULT '{"version":1,"items":[]}', + rack_updated_at INTEGER NOT NULL DEFAULT 0, + rack_local_generation INTEGER NOT NULL DEFAULT 0 CHECK(rack_local_generation >= 0), + rack_server_revision INTEGER NOT NULL DEFAULT 0 CHECK(rack_server_revision >= 0), + rack_dirty INTEGER NOT NULL DEFAULT 1 CHECK(rack_dirty IN (0, 1)), + workout_preferences_json TEXT NOT NULL DEFAULT '{"version":1}', + workout_updated_at INTEGER NOT NULL DEFAULT 0, + workout_local_generation INTEGER NOT NULL DEFAULT 0 CHECK(workout_local_generation >= 0), + workout_server_revision INTEGER NOT NULL DEFAULT 0 CHECK(workout_server_revision >= 0), + workout_dirty INTEGER NOT NULL DEFAULT 1 CHECK(workout_dirty IN (0, 1)), + led_color_scheme_id INTEGER NOT NULL DEFAULT 0 CHECK(led_color_scheme_id >= 0), + led_preferences_json TEXT NOT NULL DEFAULT '{"version":1}', + led_updated_at INTEGER NOT NULL DEFAULT 0, + led_local_generation INTEGER NOT NULL DEFAULT 0 CHECK(led_local_generation >= 0), + led_server_revision INTEGER NOT NULL DEFAULT 0 CHECK(led_server_revision >= 0), + led_dirty INTEGER NOT NULL DEFAULT 1 CHECK(led_dirty IN (0, 1)), + vbt_enabled INTEGER NOT NULL DEFAULT 1 CHECK(vbt_enabled IN (0, 1)), + vbt_preferences_json TEXT NOT NULL DEFAULT '{"version":1}', + vbt_updated_at INTEGER NOT NULL DEFAULT 0, + vbt_local_generation INTEGER NOT NULL DEFAULT 0 CHECK(vbt_local_generation >= 0), + vbt_server_revision INTEGER NOT NULL DEFAULT 0 CHECK(vbt_server_revision >= 0), + vbt_dirty INTEGER NOT NULL DEFAULT 1 CHECK(vbt_dirty IN (0, 1)), + FOREIGN KEY (profile_id) REFERENCES UserProfile(id) ON DELETE CASCADE +); + +CREATE TABLE PendingProfileLocalCleanup ( + profile_id TEXT PRIMARY KEY NOT NULL, + enqueued_at INTEGER NOT NULL +); + +CREATE TABLE PendingProfileContextRecovery ( + recovery_key TEXT PRIMARY KEY NOT NULL + DEFAULT 'active_profile_transition' + CHECK(recovery_key = 'active_profile_transition'), + prior_profile_id TEXT NOT NULL, + created_profile_id TEXT, + enqueued_at INTEGER NOT NULL +); + +INSERT OR IGNORE INTO UserProfilePreferences(profile_id) +SELECT id FROM UserProfile; diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/integration/HealthBodyWeightSyncManagerTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/integration/HealthBodyWeightSyncManagerTest.kt index 7d329a71f..b1b632fb5 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/integration/HealthBodyWeightSyncManagerTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/integration/HealthBodyWeightSyncManagerTest.kt @@ -1,24 +1,55 @@ package com.devil.phoenixproject.data.integration +import com.devil.phoenixproject.data.migration.RequiredMigrationGate +import com.devil.phoenixproject.data.migration.RequiredMigrationFailedException +import com.devil.phoenixproject.data.migration.RequiredMigrationState import com.devil.phoenixproject.domain.model.ConnectionStatus import com.devil.phoenixproject.domain.model.IntegrationProvider +import com.devil.phoenixproject.domain.model.WeightUnit +import com.devil.phoenixproject.data.repository.ActiveProfileContext import com.devil.phoenixproject.testutil.FakeExternalActivityRepository import com.devil.phoenixproject.testutil.FakeExternalMeasurementRepository import com.devil.phoenixproject.testutil.FakePreferencesManager import com.devil.phoenixproject.testutil.FakeUserProfileRepository import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNotNull +import kotlin.test.assertIs import kotlin.test.assertTrue import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runCurrent class HealthBodyWeightSyncManagerTest { + private class FakeMigrationGate( + initialState: RequiredMigrationState = RequiredMigrationState.Ready, + ) : RequiredMigrationGate { + val state = MutableStateFlow(initialState) + override val requiredMigrationState: StateFlow = + state + + override suspend fun awaitRequiredMigrations() { + when (val terminal = state.first { + it is RequiredMigrationState.Ready || it is RequiredMigrationState.Failed + }) { + RequiredMigrationState.Ready -> Unit + is RequiredMigrationState.Failed -> throw RequiredMigrationFailedException(terminal.message) + else -> error("Required migration gate returned a non-terminal state") + } + } + } + private class FakeHealthBodyWeightReader : HealthBodyWeightReader { var available = true var bodyWeightReadPermission = true var readResult: Result = Result.success(null) var readCallCount = 0 + var beforeRead: suspend () -> Unit = {} override suspend fun isAvailable(): Boolean = available @@ -26,6 +57,7 @@ class HealthBodyWeightSyncManagerTest { override suspend fun readLatestScaleBodyWeight(): Result { readCallCount++ + beforeRead() return readResult } } @@ -86,7 +118,9 @@ class HealthBodyWeightSyncManagerTest { val result = harness.manager.syncLatestFromConnectedPlatform() assertTrue(result is HealthBodyWeightSyncResult.Synced) - assertEquals(81.5f, harness.preferences.preferencesFlow.value.bodyWeightKg) + val ready = assertIs(harness.profiles.activeProfileContext.value) + assertEquals(81.5f, ready.preferences.core.value.bodyWeightKg) + assertEquals(72f, harness.preferences.preferencesFlow.value.bodyWeightKg) val measurement = harness.measurements.measurements.single() assertEquals("scale-1", measurement.externalId) assertEquals(IntegrationProvider.GOOGLE_HEALTH, measurement.provider) @@ -97,6 +131,31 @@ class HealthBodyWeightSyncManagerTest { assertNotNull(harness.activities.statusUpdates.last().lastSyncAt) } + @Test + fun measurementPersistenceFailureLeavesActiveBodyWeightUntouched() = runTest { + val harness = Harness() + harness.connectHealth() + val ready = assertIs(harness.profiles.activeProfileContext.value) + harness.profiles.updateCore( + ready.profile.id, + ready.preferences.core.value.copy(bodyWeightKg = 72f), + ) + harness.reader.readResult = Result.success(sample(weightKg = 81.5f)) + harness.measurements.upsertFailure = IllegalStateException("measurement upsert sentinel") + + val failure = assertFailsWith { + harness.manager.syncLatestFromConnectedPlatform() + } + + assertEquals("measurement upsert sentinel", failure.message) + assertEquals( + 72f, + assertIs(harness.profiles.activeProfileContext.value) + .preferences.core.value.bodyWeightKg, + ) + assertEquals(emptyList(), harness.measurements.measurements) + } + @Test fun outOfRangeSampleDoesNotOverrideManualPreferenceOrInsertMeasurement() = runTest { val harness = Harness() @@ -123,12 +182,99 @@ class HealthBodyWeightSyncManagerTest { val result = harness.manager.syncLatestFromConnectedPlatform() assertTrue(result is HealthBodyWeightSyncResult.Synced) - assertEquals(82f, harness.preferences.preferencesFlow.value.bodyWeightKg) + assertEquals( + 82f, + assertIs(harness.profiles.activeProfileContext.value) + .preferences.core.value.bodyWeightKg, + ) assertEquals(1, harness.measurements.measurements.size) assertEquals(82.0, harness.measurements.measurements.single().value) } - private class Harness { + @Test + fun profileTransitionDuringImportReturnsFailedWithoutInsertingMeasurement() = runTest { + val harness = Harness() + harness.connectHealth() + harness.reader.readResult = Result.success(sample(weightKg = 81.5f)) + harness.reader.beforeRead = { + harness.profiles.recoverPendingProfileTransitionForStartup() + } + + val result = harness.manager.syncLatestFromConnectedPlatform() + + assertIs(result) + assertEquals(emptyList(), harness.measurements.measurements) + } + + @Test + fun coreChangesDuringHealthReadArePreservedWhenBodyWeightIsWritten() = runTest { + val harness = Harness() + harness.connectHealth() + harness.reader.readResult = Result.success(sample(weightKg = 81.5f)) + harness.reader.beforeRead = { + val ready = assertIs(harness.profiles.activeProfileContext.value) + harness.profiles.updateCore( + ready.profile.id, + ready.preferences.core.value.copy( + weightUnit = WeightUnit.LB, + weightIncrement = 5f, + ), + ) + } + + val result = harness.manager.syncLatestFromConnectedPlatform() + + assertIs(result) + val core = assertIs(harness.profiles.activeProfileContext.value) + .preferences.core.value + assertEquals(81.5f, core.bodyWeightKg) + assertEquals(WeightUnit.LB, core.weightUnit) + assertEquals(5f, core.weightIncrement) + } + + @Test + fun healthImportWaitsForRequiredMigrationAndWritesActiveCore() = runTest { + val gate = FakeMigrationGate(RequiredMigrationState.Applying) + val harness = Harness(gate) + harness.connectHealth() + harness.reader.readResult = Result.success(sample(weightKg = 81f)) + + val result = async { harness.manager.syncLatestFromConnectedPlatform() } + runCurrent() + assertEquals( + 0f, + assertIs(harness.profiles.activeProfileContext.value) + .preferences.core.value.bodyWeightKg, + ) + + gate.state.value = RequiredMigrationState.Ready + + assertIs(result.await()) + assertEquals( + 81f, + assertIs(harness.profiles.activeProfileContext.value) + .preferences.core.value.bodyWeightKg, + ) + } + + @Test + fun failedRequiredMigrationReturnsFailedWithoutReadingHealthData() = runTest { + val gate = FakeMigrationGate(RequiredMigrationState.Failed("migration failed")) + val harness = Harness(gate) + harness.connectHealth() + harness.reader.readResult = Result.success(sample(weightKg = 81f)) + + val result = harness.manager.syncLatestFromConnectedPlatform() + + val failed = assertIs(result) + assertEquals("migration failed", failed.error.message) + assertEquals(0, harness.reader.readCallCount) + assertEquals(emptyList(), harness.measurements.measurements) + } + + private class Harness( + migrationGate: RequiredMigrationGate = FakeMigrationGate(), + ) { val reader = FakeHealthBodyWeightReader() val activities = FakeExternalActivityRepository() val measurements = FakeExternalMeasurementRepository() @@ -140,7 +286,7 @@ class HealthBodyWeightSyncManagerTest { bodyWeightReader = reader, externalActivityRepository = activities, externalMeasurementRepository = measurements, - preferencesManager = preferences, + requiredMigrationGate = migrationGate, userProfileRepository = profiles, providerResolver = { IntegrationProvider.GOOGLE_HEALTH }, nowProvider = { 123_456L }, diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/preferences/LegacyProfilePreferencesReaderTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/preferences/LegacyProfilePreferencesReaderTest.kt new file mode 100644 index 000000000..86401234c --- /dev/null +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/preferences/LegacyProfilePreferencesReaderTest.kt @@ -0,0 +1,260 @@ +package com.devil.phoenixproject.data.preferences + +import com.devil.phoenixproject.domain.model.WeightUnit +import com.russhwolf.settings.MapSettings +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class LegacyProfilePreferencesReaderTest { + @Test + fun legacyStallDetectionKeyStillMigratesAfterGlobalPreferenceRemoval() { + val settings = MapSettings().apply { + putBoolean("stall_detection_enabled", false) + } + val reader = SettingsLegacyProfilePreferencesReader( + SettingsPreferencesManager(settings), + settings, + ) + + val snapshot = reader.readNormalized() + + assertEquals(false, snapshot.workout.stallDetectionEnabled) + } + + @Test + fun corruptLegacyFieldsNormalizeWithoutFailingMigration() { + val settings = MapSettings().apply { + putString("weight_unit", "STONE") + putFloat("body_weight_kg", Float.NaN) + putString("equipment_rack_items_v1", "[{\"id\":\"x\"},{\"id\":\"x\"}]") + putString("exercise_defaults_broken", "{broken") + } + val reader = SettingsLegacyProfilePreferencesReader( + SettingsPreferencesManager(settings), + settings, + ) + + val snapshot = reader.readNormalized() + + assertEquals(WeightUnit.LB, snapshot.core.weightUnit) + assertEquals(0f, snapshot.core.bodyWeightKg) + assertEquals(emptyList(), snapshot.rack.items) + assertEquals(emptyMap(), snapshot.workout.singleExerciseDefaults) + assertTrue(snapshot.vbt.enabled) + } + + @Test + fun invalidNestedEntriesAreDroppedWithoutDiscardingValidSiblings() { + val settings = MapSettings().apply { + putString( + "equipment_rack_items_v1", + """[ + {"id":"plate","name":"Plate","weightKg":10.0}, + {"id":"plate","name":"Duplicate","weightKg":5.0}, + {"id":"invalid","name":"","weightKg":5.0}, + {"id":"band","name":"Band","weightKg":0.0} + ]""".trimIndent(), + ) + putString( + "exercise_defaults_press", + """{ + "exerciseId":"press", + "setReps":[5,null], + "weightPerCableKg":20.0, + "setWeightsPerCableKg":[20.0,22.5], + "progressionKg":2.5, + "setRestSeconds":[60,60], + "workoutModeId":0, + "eccentricLoadPercentage":100, + "echoLevelValue":1, + "duration":0, + "isAMRAP":false, + "perSetRestTime":true, + "defaultRackItemIds":["plate"] + }""".trimIndent(), + ) + putString( + "exercise_defaults_invalid", + """{ + "exerciseId":"invalid", + "setReps":[-1], + "weightPerCableKg":20.0, + "setWeightsPerCableKg":[20.0], + "progressionKg":2.5, + "setRestSeconds":[60], + "workoutModeId":0, + "eccentricLoadPercentage":100, + "echoLevelValue":1, + "duration":0, + "isAMRAP":false, + "perSetRestTime":true + }""".trimIndent(), + ) + } + val reader = SettingsLegacyProfilePreferencesReader( + SettingsPreferencesManager(settings), + settings, + ) + + val snapshot = reader.readNormalized() + + assertEquals(listOf("plate", "band"), snapshot.rack.items.map { it.id }) + assertEquals("Plate", snapshot.rack.items.first().name) + assertEquals(10f, snapshot.rack.items.first().weightKg) + assertEquals(setOf("press"), snapshot.workout.singleExerciseDefaults.keys) + } + + @Test + fun justLiftInvalidFieldFallsBackWithoutDiscardingValidSibling() { + val settings = MapSettings().apply { + putString( + "just_lift_defaults", + """{"weightPerCableKg":-5.0,"restSeconds":120}""", + ) + } + val reader = SettingsLegacyProfilePreferencesReader( + SettingsPreferencesManager(settings), + settings, + ) + + val defaults = reader.readNormalized().workout.justLiftDefaults + + assertEquals(20f, defaults.weightPerCableKg) + assertEquals(120, defaults.restSeconds) + } + + @Test + fun exerciseDefaultsUseEstablishedDeterministicKeyPrecedence() { + val competingEntries = listOf( + "exercise_defaults_row_DOUBLE" to exerciseDefaultsJson("row", 20f), + "exercise_defaults_row" to exerciseDefaultsJson("row", 10f), + "exercise_defaults_press_EITHER" to exerciseDefaultsJson("press", 40f), + "exercise_defaults_press_SINGLE" to exerciseDefaultsJson("press", 30f), + "exercise_defaults_squat_EITHER" to exerciseDefaultsJson("squat", 70f), + "exercise_defaults_squat_SINGLE" to exerciseDefaultsJson("squat", 60f), + "exercise_defaults_squat_DOUBLE" to exerciseDefaultsJson("squat", 50f), + "exercise_defaults_z_for_curl" to exerciseDefaultsJson("curl", 90f), + "exercise_defaults_a_for_curl" to exerciseDefaultsJson("curl", 80f), + ) + + fun read(entries: List>) = MapSettings() + .also { settings -> entries.forEach { (key, value) -> settings.putString(key, value) } } + .let { settings -> + SettingsLegacyProfilePreferencesReader( + SettingsPreferencesManager(settings), + settings, + ).readNormalized().workout.singleExerciseDefaults + } + + val insertedOutOfPrecedenceOrder = read(competingEntries) + val insertedInReverseOrder = read(competingEntries.reversed()) + + assertEquals(insertedOutOfPrecedenceOrder, insertedInReverseOrder) + assertEquals(10f, insertedOutOfPrecedenceOrder.getValue("row").weightPerCableKg) + assertEquals(30f, insertedOutOfPrecedenceOrder.getValue("press").weightPerCableKg) + assertEquals(50f, insertedOutOfPrecedenceOrder.getValue("squat").weightPerCableKg) + assertEquals(80f, insertedOutOfPrecedenceOrder.getValue("curl").weightPerCableKg) + } + + @Test + fun outOfRangeFieldsUseDocumentedDefaultsInsteadOfLegacyClamps() { + val settings = MapSettings().apply { + putInt("velocity_loss_threshold_percent", 999) + putInt("default_routine_exercise_weight_percent_of_pr", 999) + } + val reader = SettingsLegacyProfilePreferencesReader( + SettingsPreferencesManager(settings), + settings, + ) + + val snapshot = reader.readNormalized() + + assertEquals(20, snapshot.vbt.velocityLossThresholdPercent) + assertEquals(80, snapshot.workout.defaultRoutineExerciseWeightPercentOfPR) + } + + @Test + fun echoHardPlaceholderMigrationHonorsExistingOneShotMarkers() { + fun settings(markersComplete: Boolean) = MapSettings().apply { + putString( + "just_lift_defaults", + """{"workoutModeId":10,"echoLevelValue":0}""", + ) + putString("exercise_defaults_press", echoExerciseDefaultsJson()) + if (markersComplete) { + putBoolean("echo_hard_default_migrated_just_lift", true) + putBoolean("echo_hard_default_migrated_exercise_press", true) + } + } + + val legacySettings = settings(markersComplete = false) + val legacySnapshot = SettingsLegacyProfilePreferencesReader( + SettingsPreferencesManager(legacySettings), + legacySettings, + ).readNormalized() + val explicitlySavedSettings = settings(markersComplete = true) + val explicitlySavedSnapshot = SettingsLegacyProfilePreferencesReader( + SettingsPreferencesManager(explicitlySavedSettings), + explicitlySavedSettings, + ).readNormalized() + + assertEquals(1, legacySnapshot.workout.justLiftDefaults.echoLevelValue) + assertEquals(1, legacySnapshot.workout.singleExerciseDefaults.getValue("press").echoLevelValue) + assertEquals(0, explicitlySavedSnapshot.workout.justLiftDefaults.echoLevelValue) + assertEquals(0, explicitlySavedSnapshot.workout.singleExerciseDefaults.getValue("press").echoLevelValue) + } + + @Test + fun rackItemsWithoutStableIdsAreDroppedWithoutGeneratingMigrationValues() { + val settings = MapSettings().apply { + putString( + "equipment_rack_items_v1", + """[ + {"name":"Missing id","weightKg":5.0}, + {"id":"stable","name":"Stable","weightKg":10.0} + ]""".trimIndent(), + ) + } + val reader = SettingsLegacyProfilePreferencesReader( + SettingsPreferencesManager(settings), + settings, + ) + + val first = reader.readNormalized().rack.items + val second = reader.readNormalized().rack.items + + assertEquals(listOf("stable"), first.map { it.id }) + assertEquals(first, second) + } + + private fun echoExerciseDefaultsJson() = """{ + "exerciseId":"press", + "setReps":[5], + "weightPerCableKg":20.0, + "setWeightsPerCableKg":[20.0], + "progressionKg":2.5, + "setRestSeconds":[60], + "workoutModeId":10, + "eccentricLoadPercentage":100, + "echoLevelValue":0, + "duration":0, + "isAMRAP":false, + "perSetRestTime":true + }""".trimIndent() + + private fun exerciseDefaultsJson(exerciseId: String, weightPerCableKg: Float) = """{ + "exerciseId":"$exerciseId", + "setReps":[5], + "weightPerCableKg":$weightPerCableKg, + "setWeightsPerCableKg":[$weightPerCableKg], + "progressionKg":2.5, + "setRestSeconds":[60], + "workoutModeId":0, + "eccentricLoadPercentage":100, + "echoLevelValue":1, + "duration":0, + "isAMRAP":false, + "perSetRestTime":true + }""".trimIndent() +} diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/preferences/ProfilePreferencesCodecTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/preferences/ProfilePreferencesCodecTest.kt new file mode 100644 index 000000000..61eec90cb --- /dev/null +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/preferences/ProfilePreferencesCodecTest.kt @@ -0,0 +1,427 @@ +package com.devil.phoenixproject.data.preferences + +import com.devil.phoenixproject.domain.model.BleCompatibilitySetting +import com.devil.phoenixproject.domain.model.CoreProfilePreferences +import com.devil.phoenixproject.domain.model.JustLiftDefaultsDocument +import com.devil.phoenixproject.domain.model.LedPreferences +import com.devil.phoenixproject.domain.model.ProfileLocalSafetyPreferences +import com.devil.phoenixproject.domain.model.ProfilePreferenceSection +import com.devil.phoenixproject.domain.model.ProfilePreferenceValidity +import com.devil.phoenixproject.domain.model.ProfileSectionMetadata +import com.devil.phoenixproject.domain.model.RackItem +import com.devil.phoenixproject.domain.model.RackPreferences +import com.devil.phoenixproject.domain.model.RepCountTiming +import com.devil.phoenixproject.domain.model.ScalingBasis +import com.devil.phoenixproject.domain.model.UserPreferences +import com.devil.phoenixproject.domain.model.UserProfilePreferences +import com.devil.phoenixproject.domain.model.VbtPreferences +import com.devil.phoenixproject.domain.model.VulgarTier +import com.devil.phoenixproject.domain.model.WeightUnit +import com.devil.phoenixproject.domain.model.WorkoutPreferences +import com.devil.phoenixproject.util.BackupDestination +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class ProfilePreferencesCodecTest { + @Test + fun defaultsMatchVersionOneContract() { + assertEquals(WeightUnit.LB, CoreProfilePreferences().weightUnit) + assertEquals(-1f, CoreProfilePreferences().weightIncrement) + assertEquals(10, WorkoutPreferences().summaryCountdownSeconds) + assertEquals(5, WorkoutPreferences().autoStartCountdownSeconds) + assertTrue(VbtPreferences().enabled) + assertEquals(20, VbtPreferences().velocityLossThresholdPercent) + assertEquals(1, RackPreferences().version) + + val safety = ProfileLocalSafetyPreferences() + assertEquals(null, safety.safeWord) + assertFalse(safety.safeWordCalibrated) + assertFalse(safety.adultsOnlyConfirmed) + assertFalse(safety.adultsOnlyPrompted) + } + + @Test + fun compatibilityPreferencesKeepGlobalFieldsAndEnableVbtByDefault() { + val preferences = UserPreferences() + + assertTrue(preferences.vbtEnabled) + assertTrue(preferences.enableVideoPlayback) + assertFalse(preferences.autoBackupEnabled) + assertEquals(BackupDestination.Default, preferences.backupDestination) + assertEquals("en", preferences.language) + assertEquals(BleCompatibilitySetting.AUTO, preferences.bleCompatibilityMode) + assertFalse(preferences.velocityOneRepMaxBackfillDone) + } + + @Test + fun typedProfileSectionsCarryIndependentValuesAndMetadata() { + val metadata = ProfileSectionMetadata( + updatedAt = 11L, + localGeneration = 12L, + serverRevision = 13L, + dirty = true, + ) + val profile = UserProfilePreferences( + profileId = "profile-1", + schemaVersion = 1, + legacyMigrationVersion = 0, + core = ProfilePreferenceSection(CoreProfilePreferences(), validity = ProfilePreferenceValidity.Valid, metadata = metadata), + rack = ProfilePreferenceSection(RackPreferences(), validity = ProfilePreferenceValidity.Valid, metadata = metadata), + workout = ProfilePreferenceSection(WorkoutPreferences(), validity = ProfilePreferenceValidity.Valid, metadata = metadata), + led = ProfilePreferenceSection(LedPreferences(), validity = ProfilePreferenceValidity.Valid, metadata = metadata), + vbt = ProfilePreferenceSection(VbtPreferences(), validity = ProfilePreferenceValidity.Valid, metadata = metadata), + ) + + assertEquals("profile-1", profile.profileId) + assertEquals(null, profile.core.raw) + assertEquals(metadata, profile.vbt.metadata) + } + + @Test + fun malformedWorkoutDoesNotInvalidateRack() { + val rack = ProfilePreferencesCodec.decodeRack( + """{"version":1,"items":[]}""", + ) + val workout = ProfilePreferencesCodec.decodeWorkout("{not-json") + + assertEquals(ProfilePreferenceValidity.Valid, rack.validity) + assertIs(workout.validity) + assertEquals(WorkoutPreferences(), workout.value) + assertEquals("{not-json", workout.raw) + } + + @Test + fun semanticallyInvalidWorkoutRetainsRawAndReturnsFallback() { + val raw = """{"version":1,"summaryCountdownSeconds":7}""" + + val decoded = ProfilePreferencesCodec.decodeWorkout(raw) + + val invalid = assertIs(decoded.validity) + assertEquals("summaryCountdownSeconds", invalid.reason) + assertEquals(WorkoutPreferences(), decoded.value) + assertEquals(raw, decoded.raw) + } + + @Test + fun coreBodyWeightBoundaryTable() { + listOf(0f, 20f, 300f).forEach { bodyWeightKg -> + assertValid( + ProfilePreferencesValidator.core(CoreProfilePreferences(bodyWeightKg = bodyWeightKg)), + "bodyWeightKg=$bodyWeightKg", + ) + } + + listOf(19.99f, 300.01f, Float.NaN, Float.POSITIVE_INFINITY, Float.NEGATIVE_INFINITY).forEach { bodyWeightKg -> + assertInvalidField( + ProfilePreferencesValidator.core(CoreProfilePreferences(bodyWeightKg = bodyWeightKg)), + "bodyWeightKg", + "bodyWeightKg=$bodyWeightKg", + ) + } + } + + @Test + fun coreWeightIncrementBoundaryTable() { + listOf(-1f, 0.01f, Float.MIN_VALUE).forEach { increment -> + assertValid( + ProfilePreferencesValidator.core(CoreProfilePreferences(weightIncrement = increment)), + "weightIncrement=$increment", + ) + } + + listOf(0f, -0.01f, -2f, Float.NaN, Float.POSITIVE_INFINITY, Float.NEGATIVE_INFINITY).forEach { increment -> + assertInvalidField( + ProfilePreferencesValidator.core(CoreProfilePreferences(weightIncrement = increment)), + "weightIncrement", + "weightIncrement=$increment", + ) + } + } + + @Test + fun workoutTimerBoundaryTables() { + listOf(-1, 0, 5, 10, 15, 20, 25, 30).forEach { seconds -> + assertValid( + ProfilePreferencesValidator.workout(WorkoutPreferences(summaryCountdownSeconds = seconds)), + "summaryCountdownSeconds=$seconds", + ) + } + listOf(-2, 1, 4, 6, 29, 31).forEach { seconds -> + assertInvalidField( + ProfilePreferencesValidator.workout(WorkoutPreferences(summaryCountdownSeconds = seconds)), + "summaryCountdownSeconds", + "summaryCountdownSeconds=$seconds", + ) + } + + listOf(2, 10).forEach { seconds -> + assertValid( + ProfilePreferencesValidator.workout(WorkoutPreferences(autoStartCountdownSeconds = seconds)), + "autoStartCountdownSeconds=$seconds", + ) + } + listOf(1, 11).forEach { seconds -> + assertInvalidField( + ProfilePreferencesValidator.workout(WorkoutPreferences(autoStartCountdownSeconds = seconds)), + "autoStartCountdownSeconds", + "autoStartCountdownSeconds=$seconds", + ) + } + + listOf(0, 5, 300).forEach { seconds -> + assertValid( + ProfilePreferencesValidator.workout( + WorkoutPreferences(justLiftDefaults = JustLiftDefaultsDocument(restSeconds = seconds)), + ), + "justLiftDefaults.restSeconds=$seconds", + ) + } + listOf(1, 4, 301).forEach { seconds -> + assertInvalidField( + ProfilePreferencesValidator.workout( + WorkoutPreferences(justLiftDefaults = JustLiftDefaultsDocument(restSeconds = seconds)), + ), + "justLiftDefaults.restSeconds", + "justLiftDefaults.restSeconds=$seconds", + ) + } + } + + @Test + fun percentageBoundaryTables() { + listOf(50, 120).forEach { percent -> + assertValid( + ProfilePreferencesValidator.workout( + WorkoutPreferences(defaultRoutineExerciseWeightPercentOfPR = percent), + ), + "defaultRoutineExerciseWeightPercentOfPR=$percent", + ) + } + listOf(49, 121).forEach { percent -> + assertInvalidField( + ProfilePreferencesValidator.workout( + WorkoutPreferences(defaultRoutineExerciseWeightPercentOfPR = percent), + ), + "defaultRoutineExerciseWeightPercentOfPR", + "defaultRoutineExerciseWeightPercentOfPR=$percent", + ) + } + + listOf(0, 150).forEach { percent -> + assertValid( + ProfilePreferencesValidator.workout( + WorkoutPreferences(justLiftDefaults = JustLiftDefaultsDocument(eccentricLoadPercentage = percent)), + ), + "justLiftDefaults.eccentricLoadPercentage=$percent", + ) + } + listOf(-1, 151).forEach { percent -> + assertInvalidField( + ProfilePreferencesValidator.workout( + WorkoutPreferences(justLiftDefaults = JustLiftDefaultsDocument(eccentricLoadPercentage = percent)), + ), + "justLiftDefaults.eccentricLoadPercentage", + "justLiftDefaults.eccentricLoadPercentage=$percent", + ) + } + + listOf(10, 50).forEach { percent -> + assertValid(ProfilePreferencesValidator.vbt(VbtPreferences(velocityLossThresholdPercent = percent)), "vbt=$percent") + } + listOf(9, 51).forEach { percent -> + assertInvalidField( + ProfilePreferencesValidator.vbt(VbtPreferences(velocityLossThresholdPercent = percent)), + "velocityLossThresholdPercent", + "vbt=$percent", + ) + } + } + + @Test + fun nonFiniteWorkoutWeightsAreInvalid() { + listOf(Float.NaN, Float.POSITIVE_INFINITY, Float.NEGATIVE_INFINITY, -0.01f).forEach { weight -> + assertInvalidField( + ProfilePreferencesValidator.workout( + WorkoutPreferences(justLiftDefaults = JustLiftDefaultsDocument(weightPerCableKg = weight)), + ), + "justLiftDefaults.weightPerCableKg", + "justLiftDefaults.weightPerCableKg=$weight", + ) + } + } + + @Test + fun duplicateRackIdsAreInvalid() { + val rack = RackPreferences( + items = listOf( + RackItem(id = "duplicate", name = "Vest", weightKg = 10f), + RackItem(id = "duplicate", name = "Belt", weightKg = 5f), + ), + ) + + assertInvalidField(ProfilePreferencesValidator.rack(rack), "duplicateRackItemId", "duplicate rack IDs") + } + + @Test + fun unsupportedVersionsReturnFallbackAndRetainRaw() { + val rackRaw = """{"version":2,"items":[]}""" + val workoutRaw = """{"version":2}""" + val ledRaw = """{"version":2,"discoModeUnlocked":true}""" + val vbtRaw = """{"version":2,"velocityLossThresholdPercent":25}""" + + val rack = ProfilePreferencesCodec.decodeRack(rackRaw) + val workout = ProfilePreferencesCodec.decodeWorkout(workoutRaw) + val led = ProfilePreferencesCodec.decodeLed(ledRaw, colorScheme = 4) + val vbt = ProfilePreferencesCodec.decodeVbt(vbtRaw, enabled = false) + + assertIs(rack.validity) + assertEquals(RackPreferences(), rack.value) + assertEquals(rackRaw, rack.raw) + assertIs(workout.validity) + assertEquals(WorkoutPreferences(), workout.value) + assertEquals(workoutRaw, workout.raw) + assertIs(led.validity) + assertEquals(LedPreferences(colorScheme = 4), led.value) + assertEquals(ledRaw, led.raw) + assertIs(vbt.validity) + assertEquals(VbtPreferences(enabled = false), vbt.value) + assertEquals(vbtRaw, vbt.raw) + } + + @Test + fun unknownFieldsAreIgnoredAndMissingOptionalFieldsUseDefaults() { + val rack = ProfilePreferencesCodec.decodeRack("""{"version":1,"future":true}""") + val workout = ProfilePreferencesCodec.decodeWorkout("""{"version":1,"future":{"nested":1}}""") + val led = ProfilePreferencesCodec.decodeLed("""{"future":"value"}""", colorScheme = 7) + val vbt = ProfilePreferencesCodec.decodeVbt("""{"future":[1,2]}""", enabled = false) + + assertEquals(ProfilePreferenceValidity.Valid, rack.validity) + assertEquals(RackPreferences(), rack.value) + assertEquals(ProfilePreferenceValidity.Valid, workout.validity) + assertEquals(WorkoutPreferences(), workout.value) + assertEquals(ProfilePreferenceValidity.Valid, led.validity) + assertEquals(LedPreferences(colorScheme = 7), led.value) + assertEquals(ProfilePreferenceValidity.Valid, vbt.validity) + assertEquals(VbtPreferences(enabled = false), vbt.value) + } + + @Test + fun localLedJsonHasExactKeysAndOmitsRowOwnedColorScheme() { + val encoded = ProfilePreferencesCodec.encodeLed( + LedPreferences(colorScheme = 9, discoModeUnlocked = true), + ) + + assertEquals( + setOf("version", "discoModeUnlocked"), + Json.parseToJsonElement(encoded).jsonObject.keys, + ) + } + + @Test + fun localVbtJsonHasExactKeysAndOmitsRowOwnedEnabled() { + val encoded = ProfilePreferencesCodec.encodeVbt(VbtPreferences(enabled = false)) + + assertEquals( + setOf( + "version", + "velocityLossThresholdPercent", + "autoEndOnVelocityLoss", + "defaultScalingBasis", + "verbalEncouragementEnabled", + "vulgarModeEnabled", + "vulgarTier", + "dominatrixModeUnlocked", + "dominatrixModeActive", + ), + Json.parseToJsonElement(encoded).jsonObject.keys, + ) + } + + @Test + fun rowOwnedValuesAreAppliedWhenLocalDocumentsDecode() { + val led = ProfilePreferencesCodec.decodeLed( + """{"version":1,"discoModeUnlocked":true}""", + colorScheme = 6, + ) + val vbt = ProfilePreferencesCodec.decodeVbt( + """{"version":1,"velocityLossThresholdPercent":35,"vulgarTier":"MILD"}""", + enabled = false, + ) + + assertEquals(LedPreferences(colorScheme = 6, discoModeUnlocked = true), led.value) + assertEquals(6, led.value.colorScheme) + assertFalse(vbt.value.enabled) + assertEquals(35, vbt.value.velocityLossThresholdPercent) + assertEquals(VulgarTier.MILD, vbt.value.vulgarTier) + } + + @Test + fun persistedEnumsSerializeAsUppercaseNames() { + assertEquals("\"KG\"", Json.encodeToString(WeightUnit.KG)) + assertEquals("\"BOTTOM\"", Json.encodeToString(RepCountTiming.BOTTOM)) + assertEquals("\"MIX\"", Json.encodeToString(VulgarTier.MIX)) + assertEquals("\"ESTIMATED_1RM\"", Json.encodeToString(ScalingBasis.ESTIMATED_1RM)) + } + + @Test + fun singleExerciseDefaultsMapToDocumentAndBackWithoutLoss() { + val legacy = SingleExerciseDefaults( + exerciseId = "exercise-1", + setReps = listOf(8, null, 10), + weightPerCableKg = 21.5f, + setWeightsPerCableKg = listOf(21.5f, 22f, 22.5f), + progressionKg = 0.5f, + setRestSeconds = listOf(30, 45, 60), + workoutModeId = 10, + eccentricLoadPercentage = 120, + echoLevelValue = 2, + duration = 90, + isAMRAP = true, + perSetRestTime = true, + defaultRackItemIds = listOf("rack-1", "rack-2"), + ) + + val document = legacy.toDocument() + + assertEquals(legacy, document.toLegacySingleExerciseDefaults()) + } + + @Test + fun justLiftDefaultsMapToDocumentWithoutLoss() { + val legacy = JustLiftDefaults( + workoutModeId = 10, + weightPerCableKg = 24.5f, + weightChangePerRep = -0.5f, + eccentricLoadPercentage = 130, + echoLevelValue = 3, + stallDetectionEnabled = false, + repCountTimingName = "BOTTOM", + restSeconds = 75, + ) + + val document = legacy.toDocument() + + assertEquals(legacy.workoutModeId, document.workoutModeId) + assertEquals(legacy.weightPerCableKg, document.weightPerCableKg) + assertEquals(legacy.weightChangePerRep, document.weightChangePerRep) + assertEquals(legacy.eccentricLoadPercentage, document.eccentricLoadPercentage) + assertEquals(legacy.echoLevelValue, document.echoLevelValue) + assertEquals(legacy.stallDetectionEnabled, document.stallDetectionEnabled) + assertEquals(legacy.repCountTimingName, document.repCountTimingName) + assertEquals(legacy.restSeconds, document.restSeconds) + } + + private fun assertValid(errors: List, case: String) { + assertTrue(errors.isEmpty(), "Expected valid $case, got $errors") + } + + private fun assertInvalidField(errors: List, field: String, case: String) { + assertTrue(field in errors, "Expected $field for $case, got $errors") + } +} diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/repository/EquipmentRackRepositoryTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/repository/EquipmentRackRepositoryTest.kt index 981ce8ddc..26a8a9653 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/repository/EquipmentRackRepositoryTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/repository/EquipmentRackRepositoryTest.kt @@ -5,10 +5,12 @@ import com.devil.phoenixproject.domain.model.RackItem import com.devil.phoenixproject.domain.model.RackItemBehavior import com.devil.phoenixproject.domain.model.RackItemCategory import com.russhwolf.settings.MapSettings +import com.devil.phoenixproject.testutil.FakeUserProfileRepository import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.runCurrent class EquipmentRackRepositoryTest { @Test @@ -70,6 +72,27 @@ class EquipmentRackRepositoryTest { assertEquals(listOf(enabled), resolved) } + @Test + fun `equipment rack follows active profile`() = runTest { + val profiles = FakeUserProfileRepository().apply { + setActiveProfileForTest(id = "profile-a") + } + val repository = ProfileEquipmentRackRepository(profiles, backgroundScope) + runCurrent() + + repository.saveItems(listOf(rackItem("a-item", "A item", 10f))) + profiles.setActiveProfileForTest(id = "profile-b") + repository.saveItems(listOf(rackItem("b-item", "B item", 20f))) + runCurrent() + + assertEquals(listOf("b-item"), repository.getItems().map { it.id }) + assertEquals(listOf("b-item"), repository.rackItems.value.map { it.id }) + profiles.setActiveProfileForTest(id = "profile-a") + runCurrent() + assertEquals(listOf("a-item"), repository.getItems().map { it.id }) + assertEquals(listOf("a-item"), repository.rackItems.value.map { it.id }) + } + private fun rackItem( id: String, name: String, diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/repository/ProfileDeletionMergePolicyTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/repository/ProfileDeletionMergePolicyTest.kt new file mode 100644 index 000000000..3ef4966fb --- /dev/null +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/repository/ProfileDeletionMergePolicyTest.kt @@ -0,0 +1,360 @@ +package com.devil.phoenixproject.data.repository + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ProfileDeletionMergePolicyTest { + @Test + fun `max weight compares weight then one rep max then achieved time`() { + val target = personalRecord( + profileId = TARGET, + weight = 100.0, + oneRepMax = 130.0, + achievedAt = 300, + uuid = "target-uuid", + ) + + assertEquals(101.0, merge(target, personalRecord(SOURCE, weight = 101.0)).weight) + assertEquals( + 131.0, + merge(target, personalRecord(SOURCE, weight = 100.0, oneRepMax = 131.0)).oneRepMax, + ) + assertEquals( + 301, + merge( + target, + personalRecord(SOURCE, weight = 100.0, oneRepMax = 130.0, achievedAt = 301), + ).achievedAt, + ) + assertEquals( + TARGET_ID, + merge( + target, + personalRecord(SOURCE, weight = 100.0, oneRepMax = 130.0, achievedAt = 300), + ).id, + ) + } + + @Test + fun `max volume compares volume then weight then achieved time`() { + val target = personalRecord( + profileId = TARGET, + prType = "MAX_VOLUME", + volume = 1_000.0, + weight = 100.0, + achievedAt = 300, + ) + + assertEquals( + 1_001.0, + merge(target, personalRecord(SOURCE, prType = "MAX_VOLUME", volume = 1_001.0)).volume, + ) + assertEquals( + 101.0, + merge( + target, + personalRecord( + SOURCE, + prType = "MAX_VOLUME", + volume = 1_000.0, + weight = 101.0, + ), + ).weight, + ) + assertEquals( + 301, + merge( + target, + personalRecord( + SOURCE, + prType = "MAX_VOLUME", + volume = 1_000.0, + weight = 100.0, + achievedAt = 301, + ), + ).achievedAt, + ) + } + + @Test + fun `live record beats tombstone and complete tie chooses target`() { + val liveSource = personalRecord(SOURCE, weight = 1.0, deletedAt = null, serverId = "source") + val deletedTarget = personalRecord(TARGET, weight = 500.0, deletedAt = 900, serverId = "target") + val liveWinner = merge(deletedTarget, liveSource) + + assertNull(liveWinner.deletedAt) + assertEquals("source", liveWinner.serverId) + assertEquals(TARGET_ID, liveWinner.id) + + val completeTie = merge( + personalRecord(TARGET, serverId = "target"), + personalRecord(SOURCE, serverId = "source"), + ) + assertEquals("target", completeTie.serverId) + } + + @Test + fun `winner fields keep target id with uuid and name fallback`() { + val targetWins = merge( + personalRecord( + TARGET, + exerciseName = "", + weight = 200.0, + updatedAt = 20, + serverId = "target-server", + uuid = null, + ), + personalRecord( + SOURCE, + exerciseName = "Bench Press", + weight = 100.0, + updatedAt = 10, + serverId = "source-server", + uuid = "source-uuid", + ), + ) + assertEquals(TARGET_ID, targetWins.id) + assertEquals("Bench Press", targetWins.exerciseName) + assertEquals("source-uuid", targetWins.uuid) + assertEquals(20, targetWins.updatedAt) + assertEquals("target-server", targetWins.serverId) + + val sourceWins = merge( + personalRecord(TARGET, weight = 100.0, uuid = "target-uuid"), + personalRecord( + SOURCE, + weight = 200.0, + updatedAt = 30, + serverId = "source-server", + uuid = null, + ), + ) + assertEquals(TARGET_ID, sourceWins.id) + assertEquals(TARGET, sourceWins.profileId) + assertEquals("target-uuid", sourceWins.uuid) + assertEquals(30, sourceWins.updatedAt) + assertEquals("source-server", sourceWins.serverId) + assertEquals("Old School", sourceWins.workoutMode) + } + + @Test + fun `source-only personal record keeps lowest source id`() { + val retained = ProfileDeletionMergePolicy.mergePersonalRecordGroup( + records = listOf( + personalRecord(SOURCE, id = 8, weight = 200.0), + personalRecord(SOURCE, id = 7, weight = 100.0), + ), + targetProfileId = TARGET, + ) + + assertEquals(7, retained.id) + assertEquals(TARGET, retained.profileId) + assertEquals(200.0, retained.weight) + } + + @Test + fun `normalized alias groups are deterministic regardless of query order`() { + val rows = listOf( + personalRecord(TARGET, id = 12, serverId = "target-high", uuid = "target-high-uuid"), + personalRecord(TARGET, id = 10, serverId = "target-low", uuid = null), + personalRecord(SOURCE, id = 20, serverId = "source", uuid = "source-uuid"), + ) + val forward = ProfileDeletionMergePolicy.mergePersonalRecordGroup(rows, TARGET) + val reversed = ProfileDeletionMergePolicy.mergePersonalRecordGroup(rows.reversed(), TARGET) + + assertEquals(forward, reversed) + assertEquals(10, forward.id) + assertEquals("target-low", forward.serverId) + assertEquals("target-high-uuid", forward.uuid) + + val badges = listOf( + badge(TARGET, earnedAt = 100, updatedAt = 12, serverId = "target-high").copy(id = 12), + badge(TARGET, earnedAt = 100, updatedAt = 10, serverId = "target-low").copy(id = 10), + badge(SOURCE, earnedAt = 100, updatedAt = 20, serverId = "source").copy(id = 20), + ) + assertEquals( + ProfileDeletionMergePolicy.mergeEarnedBadgeGroup(badges, TARGET), + ProfileDeletionMergePolicy.mergeEarnedBadgeGroup(badges.reversed(), TARGET), + ) + + val mvts = listOf( + mvt(TARGET, personalMvtMs = 200.0, sampleCount = 0, updatedAt = 20), + mvt(TARGET, personalMvtMs = 300.0, sampleCount = 0, updatedAt = 20), + mvt(SOURCE, personalMvtMs = 400.0, sampleCount = 0, updatedAt = 20), + ) + assertEquals( + ProfileDeletionMergePolicy.mergeExerciseMvtGroup(mvts, TARGET), + ProfileDeletionMergePolicy.mergeExerciseMvtGroup(mvts.reversed(), TARGET), + ) + } + + @Test + fun `badge retains target id and combines earliest times with live metadata donor`() { + val merged = ProfileDeletionMergePolicy.mergeEarnedBadgeGroup( + badges = listOf( + badge( + TARGET, + earnedAt = 200, + celebratedAt = 250, + updatedAt = 20, + serverId = "target", + deletedAt = null, + ), + badge( + SOURCE, + earnedAt = 100, + celebratedAt = 300, + updatedAt = 10, + serverId = "source", + deletedAt = 400, + ), + ), + targetProfileId = TARGET, + ) + + assertEquals(BADGE_TARGET_ID, merged.id) + assertEquals(TARGET, merged.profileId) + assertEquals(100, merged.earnedAt) + assertEquals(250, merged.celebratedAt) + assertEquals(20, merged.updatedAt) + assertEquals("target", merged.serverId) + assertNull(merged.deletedAt) + } + + @Test + fun `badge donor uses earlier earned then target on full tie`() { + val earlierSource = ProfileDeletionMergePolicy.mergeEarnedBadgeGroup( + listOf( + badge(TARGET, earnedAt = 200, updatedAt = 20, serverId = "target"), + badge(SOURCE, earnedAt = 100, updatedAt = 10, serverId = "source"), + ), + TARGET, + ) + assertEquals("source", earlierSource.serverId) + + val tied = ProfileDeletionMergePolicy.mergeEarnedBadgeGroup( + listOf( + badge(TARGET, earnedAt = 100, updatedAt = 20, serverId = "target"), + badge(SOURCE, earnedAt = 100, updatedAt = 10, serverId = "source"), + ), + TARGET, + ) + assertEquals("target", tied.serverId) + } + + @Test + fun `mvt is count weighted and handles zero counts deterministically`() { + val weighted = ProfileDeletionMergePolicy.mergeExerciseMvtGroup( + listOf( + mvt(TARGET, personalMvtMs = 200.0, sampleCount = 2, updatedAt = 20), + mvt(SOURCE, personalMvtMs = 400.0, sampleCount = 6, updatedAt = 10), + ), + TARGET, + ) + assertEquals(350.0, weighted.personalMvtMs) + assertEquals(8, weighted.sampleCount) + assertEquals(20, weighted.updatedAt) + + val sourceNewer = ProfileDeletionMergePolicy.mergeExerciseMvtGroup( + listOf( + mvt(TARGET, personalMvtMs = 200.0, sampleCount = -3, updatedAt = 20), + mvt(SOURCE, personalMvtMs = 400.0, sampleCount = 0, updatedAt = 21), + ), + TARGET, + ) + assertEquals(400.0, sourceNewer.personalMvtMs) + assertEquals(0, sourceNewer.sampleCount) + + val targetTie = ProfileDeletionMergePolicy.mergeExerciseMvtGroup( + listOf( + mvt(TARGET, personalMvtMs = 200.0, sampleCount = 0, updatedAt = 20), + mvt(SOURCE, personalMvtMs = 400.0, sampleCount = 0, updatedAt = 20), + ), + TARGET, + ) + assertEquals(200.0, targetTie.personalMvtMs) + } + + private fun merge( + target: ProfileMergePersonalRecord, + source: ProfileMergePersonalRecord, + ): ProfileMergePersonalRecord = ProfileDeletionMergePolicy.mergePersonalRecordGroup( + listOf(target, source), + TARGET, + ) + + private fun personalRecord( + profileId: String, + id: Long = if (profileId == TARGET) TARGET_ID else SOURCE_ID, + exerciseName: String = "Bench", + weight: Double = 100.0, + reps: Long = 5, + oneRepMax: Double = 120.0, + achievedAt: Long = 300, + prType: String = "MAX_WEIGHT", + volume: Double = 500.0, + updatedAt: Long? = 10, + serverId: String? = null, + deletedAt: Long? = null, + uuid: String? = null, + ) = ProfileMergePersonalRecord( + id = id, + profileId = profileId, + exerciseId = "bench", + exerciseName = exerciseName, + weight = weight, + reps = reps, + oneRepMax = oneRepMax, + achievedAt = achievedAt, + workoutMode = "OldSchool", + prType = prType, + volume = volume, + phase = "COMBINED", + updatedAt = updatedAt, + serverId = serverId, + deletedAt = deletedAt, + cableCount = 2, + uuid = uuid, + ) + + private fun badge( + profileId: String, + earnedAt: Long, + celebratedAt: Long? = null, + updatedAt: Long? = null, + serverId: String? = null, + deletedAt: Long? = null, + ) = ProfileMergeEarnedBadge( + id = if (profileId == TARGET) BADGE_TARGET_ID else BADGE_SOURCE_ID, + profileId = profileId, + badgeId = "first-workout", + earnedAt = earnedAt, + celebratedAt = celebratedAt, + updatedAt = updatedAt, + serverId = serverId, + deletedAt = deletedAt, + ) + + private fun mvt( + profileId: String, + personalMvtMs: Double, + sampleCount: Long, + updatedAt: Long, + ) = ProfileMergeExerciseMvt( + exerciseId = "bench", + profileId = profileId, + personalMvtMs = personalMvtMs, + sampleCount = sampleCount, + updatedAt = updatedAt, + ) + + private companion object { + const val TARGET = "target" + const val SOURCE = "source" + const val TARGET_ID = 10L + const val SOURCE_ID = 20L + const val BADGE_TARGET_ID = 30L + const val BADGE_SOURCE_ID = 40L + } +} diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/BackendHandoffContractTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/BackendHandoffContractTest.kt new file mode 100644 index 000000000..ad6f1e312 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/BackendHandoffContractTest.kt @@ -0,0 +1,1359 @@ +package com.devil.phoenixproject.data.sync + +import com.devil.phoenixproject.data.auth.sha256 +import com.devil.phoenixproject.testutil.readProjectFile +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertFailsWith +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class BackendHandoffContractTest { + private fun sql(): String = assertNotNull( + readProjectFile("docs/backend-handoff/profile-preferences-supabase.sql"), + "Supabase handoff SQL must be tracked in the mobile repository", + ) + + private fun edgeContract(): String = assertNotNull( + readProjectFile("docs/backend-handoff/profile-preferences-edge-functions.md"), + "Edge Function handoff must be tracked in the mobile repository", + ) + + private fun byteGoldenArtifact(): String = assertNotNull( + readProjectFile("docs/backend-handoff/profile-preference-byte-goldens.json"), + "Shared profile preference byte goldens must be tracked in the mobile repository", + ) + + private val exactByteGoldenArtifact = """ + { + "version": 1, + "paddingMarker": "__ASCII_PADDING__", + "sectionMarker": "__SECTION_JSON__", + "sectionRawTemplate": "{\"localProfileId\":\"profile-a\",\"section\":\"RACK\",\"documentVersion\":1,\"baseRevision\":0,\"clientModifiedAt\":\"2026-07-11T12:00:00Z\",\"payload\":{\"version\":1,\"items\":[{\"id\":\"rack-a\",\"name\":\"π界🙂\\\"\\\\__ASCII_PADDING__\",\"category\":\"OTHER\",\"weightKg\":20.0,\"behavior\":\"DISPLAY_ONLY\",\"enabled\":true,\"sortOrder\":0,\"createdAt\":-1e3,\"updatedAt\":0}]}}", + "requestRawTemplate": "{\"deviceId\":\"golden-device\",\"platform\":\"android\",\"lastSync\":0,\"profileId\":\"profile-a\",\"profileName\":\"π界🙂\\\"\\\\__ASCII_PADDING__\",\"profilePreferenceSections\":[__SECTION_JSON__]}", + "sectionTargetBytes": [262143, 262144, 262145], + "requestTargetBytes": [524287, 524288, 524289] + } + """.trimIndent() + + private fun normalizedTrackedText(value: String): String = + value.replace("\r\n", "\n").removeSuffix("\n") + + private val SHA256_EMPTY_RED_SENTINEL = + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + + // Task 2 RED only. Replace this RHS after reviewing the complete amended handoff. + private val EXPECTED_EDGE_HANDOFF_SHA256 = + "8d4dcf592e7c27e7be52aa2179e85cf5e90e151d38a6072f2452c5525ff5a3a2" + + private fun normalizedEdgeHandoff(value: String): String = + value.replace("\r\n", "\n").replace('\r', '\n') + + private fun ByteArray.lowerHex(): String = joinToString("") { + (it.toInt() and 0xff).toString(16).padStart(2, '0') + } + + private fun edgeHandoffSha256(value: String): String = + sha256(normalizedEdgeHandoff(value).encodeToByteArray()).lowerHex() + + private fun executableTypeScript(): String = Regex( + pattern = """(?s)```typescript\s+(.*?)```""", + ).findAll(edgeContract()) + .joinToString("\n") { match -> match.groupValues[1] } + .replace(Regex("""(?s)/[*].*?[*]/"""), " ") + .lineSequence() + .map { line -> line.substringBefore("//") } + .joinToString("\n") + + private fun portalTestManifest(): Set { + val matches = Regex("""(?s)```portal-test-manifest\s+(.*?)```""") + .findAll(edgeContract()) + .toList() + assertEquals(1, matches.size, "Expected exactly one portal test manifest") + val lines = matches.single().groupValues[1] + .lineSequence() + .map(String::trim) + .filter(String::isNotEmpty) + .toList() + assertEquals(lines.size, lines.toSet().size, "Portal test manifest entries must be unique") + return lines.toSet() + } + + private fun normalizedSql(): String = normalizedSql(sql()) + + private fun normalizedSql(value: String): String { + val statements = normalizedTopLevelStatements(value) + return if (statements.isEmpty()) { + "" + } else { + statements.joinToString(separator = "; ", postfix = ";") + } + } + + private fun normalizedTopLevelStatements(value: String): List = + topLevelStatements(value).map { statement -> + normalizeStatement(statement) + } + + private fun normalizeStatement(value: String): String { + val compact = value + .replace(Regex("""\s+"""), " ") + .trim() + return lowercaseOutsideLiterals(compact) + } + + private fun topLevelStatements(value: String): List { + val statements = mutableListOf() + val statement = StringBuilder() + var quote: Char? = null + var dollarDelimiter: String? = null + var inLineComment = false + var blockCommentDepth = 0 + var index = 0 + + fun finishStatement() { + val completed = statement.toString().trim() + if (completed.isNotEmpty()) { + statements += completed + } + statement.clear() + } + + while (index < value.length) { + val character = value[index] + val next = value.getOrNull(index + 1) + + if (dollarDelimiter != null) { + if (value.startsWith(dollarDelimiter, startIndex = index)) { + statement.append(dollarDelimiter) + index += dollarDelimiter.length + dollarDelimiter = null + } else { + statement.append(character) + index += 1 + } + continue + } + + if (inLineComment) { + if (character == '\n' || character == '\r') { + statement.append(' ') + inLineComment = false + } + index += 1 + continue + } + + if (blockCommentDepth > 0) { + when { + character == '/' && next == '*' -> { + blockCommentDepth += 1 + index += 2 + } + character == '*' && next == '/' -> { + blockCommentDepth -= 1 + index += 2 + if (blockCommentDepth == 0) { + statement.append(' ') + } + } + else -> index += 1 + } + continue + } + + if (quote != null) { + statement.append(character) + if (character == quote && next == quote) { + statement.append(next) + index += 2 + } else { + if (character == quote) { + quote = null + } + index += 1 + } + continue + } + + when { + character == '-' && next == '-' -> { + inLineComment = true + index += 2 + } + character == '/' && next == '*' -> { + blockCommentDepth = 1 + index += 2 + } + character == '\'' || character == '"' -> { + quote = character + statement.append(character) + index += 1 + } + character == '$' -> { + val delimiter = dollarQuoteDelimiterAt(value, index) + if (delimiter == null) { + statement.append(character) + index += 1 + } else { + dollarDelimiter = delimiter + statement.append(delimiter) + index += delimiter.length + } + } + character == ';' -> { + finishStatement() + index += 1 + } + else -> { + statement.append(character) + index += 1 + } + } + } + + assertTrue(quote == null, "Unterminated quoted SQL token") + assertTrue(dollarDelimiter == null, "Unterminated dollar-quoted SQL body") + assertEquals(0, blockCommentDepth, "Unterminated SQL block comment") + finishStatement() + return statements + } + + private fun dollarQuoteDelimiterAt(value: String, index: Int): String? { + if (value.getOrNull(index) != '$') return null + val previous = value.getOrNull(index - 1) + if (previous != null && (previous.isLetterOrDigit() || previous == '_' || previous == '$')) { + return null + } + + var cursor = index + 1 + if (value.getOrNull(cursor) == '$') { + return value.substring(index, cursor + 1) + } + val firstTagCharacter = value.getOrNull(cursor) ?: return null + if (!(firstTagCharacter.isLetter() || firstTagCharacter == '_')) return null + cursor += 1 + while (cursor < value.length) { + val character = value[cursor] + when { + character == '$' -> return value.substring(index, cursor + 1) + character.isLetterOrDigit() || character == '_' -> cursor += 1 + else -> return null + } + } + return null + } + + private fun lowercaseOutsideLiterals(value: String): String = buildString(value.length) { + var quote: Char? = null + var index = 0 + while (index < value.length) { + val character = value[index] + if ( + quote != null && + character == quote && + index + 1 < value.length && + value[index + 1] == quote + ) { + append(character) + append(value[index + 1]) + index += 2 + continue + } + if (quote != null) { + append(character) + if (character == quote) { + quote = null + } + } else if (character == '\'' || character == '"') { + append(character) + quote = character + } else if ( + character == ' ' && + ((length > 0 && this[length - 1] == '(') || + (index + 1 < value.length && value[index + 1] == ')')) + ) { + // Normalize formatting-only whitespace inside SQL parentheses. + } else { + append(character.lowercaseChar()) + } + index += 1 + } + } + + private fun tableEntries(sql: String): List { + val body = assertNotNull( + Regex( + """(?s)\bcreate table public[.]local_profile_preferences\s*[(](.*?)[)]\s*;""", + ).find(sql), + "Expected one executable local_profile_preferences table declaration", + ).groupValues[1] + return splitTopLevel(body) + } + + private fun splitTopLevel(value: String): List { + val entries = mutableListOf() + var depth = 0 + var inLiteral = false + var start = 0 + var index = 0 + while (index < value.length) { + val character = value[index] + if ( + character == '\'' && + inLiteral && + index + 1 < value.length && + value[index + 1] == '\'' + ) { + index += 2 + continue + } + when { + character == '\'' -> inLiteral = !inLiteral + !inLiteral && character == '(' -> depth += 1 + !inLiteral && character == ')' -> depth -= 1 + !inLiteral && character == ',' && depth == 0 -> { + entries += value.substring(start, index).trim() + start = index + 1 + } + } + index += 1 + } + entries += value.substring(start).trim() + return entries.filter { entry -> entry.isNotEmpty() } + } + + private fun exactTask1SecurityStatements(): List = listOf( + "alter table public.local_profile_preferences enable row level security", + "create policy local_profile_preferences_owner_select " + + "on public.local_profile_preferences for select to authenticated " + + "using ((select auth.uid()) = user_id)", + "create policy local_profile_preferences_owner_insert " + + "on public.local_profile_preferences for insert to authenticated " + + "with check ((select auth.uid()) = user_id)", + "create policy local_profile_preferences_owner_update " + + "on public.local_profile_preferences for update to authenticated " + + "using ((select auth.uid()) = user_id) " + + "with check ((select auth.uid()) = user_id)", + "create policy local_profile_preferences_owner_delete " + + "on public.local_profile_preferences for delete to authenticated " + + "using ((select auth.uid()) = user_id)", + "revoke all on table public.local_profile_preferences from public", + "revoke all on table public.local_profile_preferences from anon", + "revoke all on table public.local_profile_preferences from authenticated", + "grant select, insert, update, delete on table " + + "public.local_profile_preferences to service_role", + ) + + private fun exactFunctionAclStatements(): List = listOf( + "revoke all on function public.local_profile_preference_section_canonical(" + + "public.local_profile_preferences, text) from public, anon, authenticated", + "revoke all on function public.mutate_local_profile_preference_section(" + + "uuid, text, text, integer, bigint, jsonb) from public, anon, authenticated", + "grant execute on function public.local_profile_preference_section_canonical(" + + "public.local_profile_preferences, text) to service_role", + "grant execute on function public.mutate_local_profile_preference_section(" + + "uuid, text, text, integer, bigint, jsonb) to service_role", + ) + + private fun exactPreflightStatement(): String { + val delimiter = "\$preflight\$" + return normalizeStatement( + """ + DO $delimiter + DECLARE + matching_key text[]; + BEGIN + IF to_regclass('public.local_profiles') IS NULL THEN + RAISE EXCEPTION + 'profile preferences preflight: public.local_profiles does not exist'; + END IF; + + SELECT array_agg(attribute.attname::text ORDER BY key_column.ordinality) + INTO matching_key + FROM pg_constraint constraint_row + CROSS JOIN LATERAL unnest(constraint_row.conkey) + WITH ORDINALITY AS key_column(attnum, ordinality) + JOIN pg_attribute attribute + ON attribute.attrelid = constraint_row.conrelid + AND attribute.attnum = key_column.attnum + WHERE constraint_row.conrelid = 'public.local_profiles'::regclass + AND constraint_row.contype IN ('p', 'u') + GROUP BY constraint_row.oid + HAVING array_agg(attribute.attname::text ORDER BY key_column.ordinality) + = ARRAY['user_id', 'id']::text[] + LIMIT 1; + + IF matching_key IS NULL THEN + RAISE EXCEPTION + 'profile preferences preflight: expected unique local_profiles(user_id, id) parent key'; + END IF; + END + $delimiter + """.trimIndent(), + ) + } + + /* + * These complete SQL literals are independent test oracles. Keep them separate from sql(). + * Any executable addition inside either dollar-quoted function body must fail this contract. + */ + private val EXACT_CANONICAL_FUNCTION_SQL = """ + CREATE FUNCTION public.local_profile_preference_section_canonical( + p_row public.local_profile_preferences, + p_section text + ) RETURNS jsonb + LANGUAGE sql + STABLE + SECURITY INVOKER + SET search_path = '' + AS ${'$'}canonical${'$'} + SELECT jsonb_build_object( + 'localProfileId', p_row.local_profile_id, + 'section', p_section, + 'documentVersion', CASE p_section + WHEN 'CORE' THEN 1 + WHEN 'RACK' THEN (p_row.equipment_rack ->> 'version')::integer + WHEN 'WORKOUT' THEN (p_row.workout_preferences ->> 'version')::integer + WHEN 'LED' THEN (p_row.led_preferences ->> 'version')::integer + WHEN 'VBT' THEN (p_row.vbt_preferences ->> 'version')::integer + END, + 'serverRevision', CASE p_section + WHEN 'CORE' THEN p_row.core_revision + WHEN 'RACK' THEN p_row.rack_revision + WHEN 'WORKOUT' THEN p_row.workout_revision + WHEN 'LED' THEN p_row.led_revision + WHEN 'VBT' THEN p_row.vbt_revision + END, + 'serverUpdatedAt', to_char( + (CASE p_section + WHEN 'CORE' THEN p_row.core_updated_at + WHEN 'RACK' THEN p_row.rack_updated_at + WHEN 'WORKOUT' THEN p_row.workout_updated_at + WHEN 'LED' THEN p_row.led_updated_at + WHEN 'VBT' THEN p_row.vbt_updated_at + END) AT TIME ZONE 'UTC', + 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"' + ), + 'payload', CASE p_section + WHEN 'CORE' THEN jsonb_build_object( + 'bodyWeightKg', p_row.body_weight_kg, + 'weightUnit', p_row.weight_unit, + 'weightIncrement', p_row.weight_increment + ) + WHEN 'RACK' THEN p_row.equipment_rack + WHEN 'WORKOUT' THEN p_row.workout_preferences + WHEN 'LED' THEN jsonb_build_object( + 'ledColorSchemeId', p_row.led_color_scheme_id, + 'preferences', p_row.led_preferences + ) + WHEN 'VBT' THEN jsonb_build_object( + 'vbtEnabled', p_row.vbt_enabled, + 'preferences', p_row.vbt_preferences + ) + END + ); + ${'$'}canonical${'$'} + """.trimIndent() + + private val EXACT_MUTATION_FUNCTION_SQL = """ + CREATE FUNCTION public.mutate_local_profile_preference_section( + p_user_id uuid, + p_local_profile_id text, + p_section text, + p_document_version integer, + p_base_revision bigint, + p_payload jsonb + ) RETURNS TABLE ( + accepted boolean, + rejection_reason text, + server_revision bigint, + canonical_section jsonb + ) + LANGUAGE plpgsql + SECURITY INVOKER + SET search_path = '' + AS ${'$'}mutation${'$'} + DECLARE + current_row public.local_profile_preferences%ROWTYPE; + current_revision bigint; + BEGIN + IF p_section IS NULL OR p_section NOT IN ('CORE', 'RACK', 'WORKOUT', 'LED', 'VBT') THEN + RETURN QUERY SELECT false, 'UNSUPPORTED_SECTION', 0::bigint, NULL::jsonb; + RETURN; + END IF; + IF p_document_version IS NULL OR p_document_version <> 1 THEN + RETURN QUERY SELECT false, 'UNSUPPORTED_DOCUMENT_VERSION', 0::bigint, NULL::jsonb; + RETURN; + END IF; + IF p_base_revision IS NULL OR p_base_revision < 0 + OR p_payload IS NULL OR jsonb_typeof(p_payload) <> 'object' THEN + RETURN QUERY SELECT false, 'VALIDATION_FAILED', 0::bigint, NULL::jsonb; + RETURN; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM public.local_profiles + WHERE user_id = p_user_id AND id = p_local_profile_id + ) THEN + RETURN QUERY SELECT false, 'UNKNOWN_PROFILE', 0::bigint, NULL::jsonb; + RETURN; + END IF; + + CASE p_section + WHEN 'CORE' THEN + UPDATE public.local_profile_preferences + SET body_weight_kg = (p_payload ->> 'bodyWeightKg')::double precision, + weight_unit = p_payload ->> 'weightUnit', + weight_increment = (p_payload ->> 'weightIncrement')::double precision, + core_revision = core_revision + 1, + core_updated_at = clock_timestamp() + WHERE user_id = p_user_id + AND local_profile_id = p_local_profile_id + AND core_revision = p_base_revision + RETURNING * INTO current_row; + WHEN 'RACK' THEN + UPDATE public.local_profile_preferences + SET equipment_rack = p_payload, + rack_revision = rack_revision + 1, + rack_updated_at = clock_timestamp() + WHERE user_id = p_user_id + AND local_profile_id = p_local_profile_id + AND rack_revision = p_base_revision + RETURNING * INTO current_row; + WHEN 'WORKOUT' THEN + UPDATE public.local_profile_preferences + SET workout_preferences = p_payload, + workout_revision = workout_revision + 1, + workout_updated_at = clock_timestamp() + WHERE user_id = p_user_id + AND local_profile_id = p_local_profile_id + AND workout_revision = p_base_revision + RETURNING * INTO current_row; + WHEN 'LED' THEN + UPDATE public.local_profile_preferences + SET led_color_scheme_id = (p_payload ->> 'ledColorSchemeId')::integer, + led_preferences = p_payload -> 'preferences', + led_revision = led_revision + 1, + led_updated_at = clock_timestamp() + WHERE user_id = p_user_id + AND local_profile_id = p_local_profile_id + AND led_revision = p_base_revision + RETURNING * INTO current_row; + WHEN 'VBT' THEN + UPDATE public.local_profile_preferences + SET vbt_enabled = (p_payload ->> 'vbtEnabled')::boolean, + vbt_preferences = p_payload -> 'preferences', + vbt_revision = vbt_revision + 1, + vbt_updated_at = clock_timestamp() + WHERE user_id = p_user_id + AND local_profile_id = p_local_profile_id + AND vbt_revision = p_base_revision + RETURNING * INTO current_row; + END CASE; + + IF FOUND THEN + canonical_section := public.local_profile_preference_section_canonical(current_row, p_section); + server_revision := (canonical_section ->> 'serverRevision')::bigint; + RETURN QUERY SELECT true, NULL::text, server_revision, canonical_section; + RETURN; + END IF; + + IF p_base_revision = 0 THEN + INSERT INTO public.local_profile_preferences ( + user_id, local_profile_id, + body_weight_kg, weight_unit, weight_increment, core_revision, + equipment_rack, rack_revision, + workout_preferences, workout_revision, + led_color_scheme_id, led_preferences, led_revision, + vbt_enabled, vbt_preferences, vbt_revision + ) VALUES ( + p_user_id, + p_local_profile_id, + CASE WHEN p_section = 'CORE' THEN (p_payload ->> 'bodyWeightKg')::double precision ELSE 0 END, + CASE WHEN p_section = 'CORE' THEN p_payload ->> 'weightUnit' ELSE 'LB' END, + CASE WHEN p_section = 'CORE' THEN (p_payload ->> 'weightIncrement')::double precision ELSE -1 END, + CASE WHEN p_section = 'CORE' THEN 1 ELSE 0 END, + CASE WHEN p_section = 'RACK' THEN p_payload ELSE '{"version":1,"items":[]}'::jsonb END, + CASE WHEN p_section = 'RACK' THEN 1 ELSE 0 END, + CASE WHEN p_section = 'WORKOUT' THEN p_payload ELSE '{"version":1}'::jsonb END, + CASE WHEN p_section = 'WORKOUT' THEN 1 ELSE 0 END, + CASE WHEN p_section = 'LED' THEN (p_payload ->> 'ledColorSchemeId')::integer ELSE 0 END, + CASE WHEN p_section = 'LED' THEN p_payload -> 'preferences' ELSE '{"version":1,"discoModeUnlocked":false}'::jsonb END, + CASE WHEN p_section = 'LED' THEN 1 ELSE 0 END, + CASE WHEN p_section = 'VBT' THEN (p_payload ->> 'vbtEnabled')::boolean ELSE true END, + CASE WHEN p_section = 'VBT' THEN p_payload -> 'preferences' ELSE '{"version":1,"velocityLossThresholdPercent":20,"autoEndOnVelocityLoss":false,"defaultScalingBasis":"MAX_WEIGHT_PR","verbalEncouragementEnabled":false,"vulgarModeEnabled":false,"vulgarTier":"STRONG","dominatrixModeUnlocked":false,"dominatrixModeActive":false}'::jsonb END, + CASE WHEN p_section = 'VBT' THEN 1 ELSE 0 END + ) + ON CONFLICT (user_id, local_profile_id) DO NOTHING + RETURNING * INTO current_row; + + IF FOUND THEN + canonical_section := public.local_profile_preference_section_canonical(current_row, p_section); + server_revision := (canonical_section ->> 'serverRevision')::bigint; + RETURN QUERY SELECT true, NULL::text, server_revision, canonical_section; + RETURN; + END IF; + + CASE p_section + WHEN 'CORE' THEN + UPDATE public.local_profile_preferences + SET body_weight_kg = (p_payload ->> 'bodyWeightKg')::double precision, + weight_unit = p_payload ->> 'weightUnit', + weight_increment = (p_payload ->> 'weightIncrement')::double precision, + core_revision = 1, + core_updated_at = clock_timestamp() + WHERE user_id = p_user_id AND local_profile_id = p_local_profile_id AND core_revision = 0 + RETURNING * INTO current_row; + WHEN 'RACK' THEN + UPDATE public.local_profile_preferences + SET equipment_rack = p_payload, rack_revision = 1, rack_updated_at = clock_timestamp() + WHERE user_id = p_user_id AND local_profile_id = p_local_profile_id AND rack_revision = 0 + RETURNING * INTO current_row; + WHEN 'WORKOUT' THEN + UPDATE public.local_profile_preferences + SET workout_preferences = p_payload, workout_revision = 1, workout_updated_at = clock_timestamp() + WHERE user_id = p_user_id AND local_profile_id = p_local_profile_id AND workout_revision = 0 + RETURNING * INTO current_row; + WHEN 'LED' THEN + UPDATE public.local_profile_preferences + SET led_color_scheme_id = (p_payload ->> 'ledColorSchemeId')::integer, + led_preferences = p_payload -> 'preferences', + led_revision = 1, + led_updated_at = clock_timestamp() + WHERE user_id = p_user_id AND local_profile_id = p_local_profile_id AND led_revision = 0 + RETURNING * INTO current_row; + WHEN 'VBT' THEN + UPDATE public.local_profile_preferences + SET vbt_enabled = (p_payload ->> 'vbtEnabled')::boolean, + vbt_preferences = p_payload -> 'preferences', + vbt_revision = 1, + vbt_updated_at = clock_timestamp() + WHERE user_id = p_user_id AND local_profile_id = p_local_profile_id AND vbt_revision = 0 + RETURNING * INTO current_row; + END CASE; + + IF FOUND THEN + canonical_section := public.local_profile_preference_section_canonical(current_row, p_section); + server_revision := (canonical_section ->> 'serverRevision')::bigint; + RETURN QUERY SELECT true, NULL::text, server_revision, canonical_section; + RETURN; + END IF; + END IF; + + SELECT * INTO current_row + FROM public.local_profile_preferences + WHERE user_id = p_user_id AND local_profile_id = p_local_profile_id; + + IF NOT FOUND THEN + RETURN QUERY SELECT false, 'REVISION_CONFLICT', 0::bigint, NULL::jsonb; + RETURN; + END IF; + + canonical_section := public.local_profile_preference_section_canonical(current_row, p_section); + current_revision := (canonical_section ->> 'serverRevision')::bigint; + RETURN QUERY SELECT false, 'REVISION_CONFLICT', current_revision, canonical_section; + END + ${'$'}mutation${'$'} + """.trimIndent() + + private val exactCanonicalFunctionStatement = + normalizeStatement(EXACT_CANONICAL_FUNCTION_SQL) + private val exactMutationFunctionStatement = + normalizeStatement(EXACT_MUTATION_FUNCTION_SQL) + + private fun assertFinalExecutableEnvelope(statements: List) { + assertEquals(19, statements.size, "Expected the exact final atomic statement chain") + assertEquals("begin", statements[0]) + assertEquals(exactPreflightStatement(), statements[1]) + assertTrue(statements[2].startsWith("create table public.local_profile_preferences(")) + assertEquals(exactCanonicalFunctionStatement, statements[3]) + assertEquals(exactMutationFunctionStatement, statements[4]) + assertEquals(exactFunctionAclStatements(), statements.subList(5, 9)) + assertEquals(exactTask1SecurityStatements(), statements.subList(9, 18)) + assertEquals("commit", statements[18]) + } + + private fun assertExactPreflight(preflight: String) { + assertEquals( + exactPreflightStatement(), + preflight, + "Only the exact approved preflight body is allowed", + ) + val delimiter = "\$preflight\$" + assertTrue( + preflight.startsWith("do $delimiter declare matching_key text[]; begin "), + "The preflight DO block must immediately follow BEGIN", + ) + assertTrue( + preflight.endsWith("end $delimiter"), + "The preflight must be one closed tagged dollar body", + ) + assertTrue( + preflight.contains( + "if to_regclass('public.local_profiles') is null then " + + "raise exception " + + "'profile preferences preflight: public.local_profiles does not exist'; " + + "end if;", + ), + "The preflight must abort when the parent table is absent", + ) + assertTrue( + preflight.contains( + "where constraint_row.conrelid = 'public.local_profiles'::regclass", + ), + "The parent-key lookup must use a regclass cast", + ) + assertTrue( + preflight.contains("constraint_row.contype in ('p', 'u')"), + "The parent key must be backed by a primary or unique constraint", + ) + + val aggregate = "array_agg(attribute.attname::text order by key_column.ordinality)" + assertEquals(2, Regex(Regex.escape(aggregate)).findAll(preflight).count()) + assertTrue(preflight.contains("select $aggregate into matching_key")) + assertTrue( + preflight.contains( + "having $aggregate = array['user_id', 'id']::text[]", + ), + "The ordered parent-key comparison must retain its text-array cast", + ) + assertTrue( + preflight.contains( + "if matching_key is null then raise exception " + + "'profile preferences preflight: expected unique " + + "local_profiles(user_id, id) parent key'; end if;", + ), + "The preflight must abort when the composite parent key is absent", + ) + + } + + @Test + fun topLevelScannerHonorsQuotesCommentsAndDollarBodies() { + val guard = "\$guard\$" + val statements = topLevelStatements( + """ + BEGIN; + DO $guard + BEGIN + PERFORM 'literal; still -- literal; and ''quoted'''; + PERFORM "quoted;identifier"; + -- ignored ; SET ROLE attacker; + /* ignored ; ALTER TABLE hidden OWNER TO attacker; */ + END + $guard; + -- outside ignored ; SET SESSION AUTHORIZATION attacker; + /* outside ignored ; ALTER TABLE hidden OWNER TO attacker; */ + SELECT 'outside;literal', "outside;identifier"; + COMMIT; + """.trimIndent(), + ) + + assertEquals(4, statements.size) + assertEquals("BEGIN", statements.first()) + assertTrue(statements[1].startsWith("DO $guard")) + assertTrue(statements[2].startsWith("SELECT ")) + assertEquals("COMMIT", statements.last()) + } + + @Test + fun `sql handoff has the exact functions ACLs and 19 statement envelope`() { + val statements = normalizedTopLevelStatements(sql()) + assertFinalExecutableEnvelope(statements) + assertEquals(2, statements.count { it.startsWith("create function ") }) + assertEquals( + exactFunctionAclStatements(), + statements.filter { " on function " in it }, + ) + val tableAcls = statements.filter { + (" on table public.local_profile_preferences " in it) && + (it.startsWith("revoke ") || it.startsWith("grant ")) + } + assertEquals(exactTask1SecurityStatements().takeLast(4), tableAcls) + } + + @Test + fun `exact function bodies reject executable additions`() { + val valid = sql() + val mutations = mapOf( + "canonical DELETE" to valid.replace( + " SELECT jsonb_build_object(", + " DELETE FROM public.local_profile_preferences;\n" + + " SELECT jsonb_build_object(", + ), + "mutation PERFORM" to valid.replace( + "BEGIN\n IF p_section IS NULL", + "BEGIN\n PERFORM now();\n IF p_section IS NULL", + ), + "mutation dblink_exec" to valid.replace( + "BEGIN\n IF p_section IS NULL", + "BEGIN\n PERFORM dblink_exec('remote', " + + "'DELETE FROM public.local_profiles');\n" + + " IF p_section IS NULL", + ), + "mutation role change" to valid.replace( + "BEGIN\n IF p_section IS NULL", + "BEGIN\n SET LOCAL ROLE authenticated;\n IF p_section IS NULL", + ), + "mutation transaction control" to valid.replace( + "BEGIN\n IF p_section IS NULL", + "BEGIN\n COMMIT;\n IF p_section IS NULL", + ), + ) + mutations.forEach { (name, mutation) -> + assertFailsWith("Must reject $name") { + assertFinalExecutableEnvelope(normalizedTopLevelStatements(mutation)) + } + } + } + + @Test + fun `edge executable contract authenticates validates fully then performs scoped admin calls`() { + val code = executableTypeScript() + listOf( + "auth.getUser(userJwt)", + "bearerMatch", + "^Bearer ([^\\s]+)$", + "authOperationalFailure", + "status === 400 || status === 401 || status === 403", + "Object.hasOwn(authRecord, \"error\")", + "Object.hasOwn(authRecord, \"data\")", + "{ status: 503 }", + "req.arrayBuffer()", + "TextDecoder(\"utf-8\", { fatal: true", + "hasLeadingUtf8Bom", + "SUPABASE_SERVICE_ROLE_KEY", + "parsePreferenceEnvelope", + "validateCorePayload", + "validateRackPayload", + "validateWorkoutPayload", + "validateLedPayload", + "validateVbtPayload", + "LOCAL_ONLY_KEYS", + "requirePostgresTextTree", + "requireInt32", + "requireFloat32", + "!predicate(value)", + "!predicate(narrowed)", + "requireSafeJsonLong", + "requireRfc3339Instant", + "INT32_MIN = -2_147_483_648", + "originalBodyBytes.byteLength", + "MAX_PROFILE_PREFERENCE_SECTION_BYTES = 262_144", + "MAX_PROFILE_PREFERENCE_REQUEST_BYTES = 524_288", + "scanTopLevelJsonObject", + "preferenceElementSpans", + "rawPreferenceElementBytes", + "rawBodyBytes > MAX_PROFILE_PREFERENCE_REQUEST_BYTES", + "duplicateIdentities", + "validatedMutations", + "admin.rpc(\"mutate_local_profile_preference_section\"", + "p_user_id: verifiedUserId", + ".eq(\"user_id\", verifiedUserId)", + ".eq(\"local_profile_id\", requestedProfileId)", + "throw new PreferenceInfrastructureError", + "console.error({ name: safeErrorName(error", + "normalized.toISOString()", + ).forEach { fragment -> assertTrue(fragment in code, fragment) } + + val float32Block = assertNotNull( + Regex( + """(?s)const requireFloat32 = [(](.*?)\n};\s*const requireInt32 = [(]""", + ).find(code), + "Missing bounded requireFloat32 implementation", + ).groupValues[1] + val originalPredicate = float32Block.indexOf("!predicate(value)") + val narrowedPredicate = float32Block.indexOf("!predicate(narrowed)") + assertTrue(originalPredicate >= 0, "Float32 must validate the original wire value") + assertTrue( + narrowedPredicate > originalPredicate, + "Float32 must validate the narrowed value after the original value", + ) + + fun quotedInitializer(pattern: String): List { + val body = assertNotNull( + Regex(pattern).find(code), + "Missing exact TypeScript allowlist: $pattern", + ).groupValues[1] + return Regex("\"([^\"]+)\"") + .findAll(body) + .map { match -> match.groupValues[1] } + .toList() + } + assertEquals( + listOf( + "deviceId", "platform", "lastSync", "sessions", "telemetry", "routines", + "deletedRoutineIds", "cycles", "deletedCycleIds", "rpgAttributes", "badges", + "gamificationStats", "phaseStatistics", "exerciseSignatures", "assessments", + "customExercises", "profileId", "profileName", "allProfiles", + "externalActivities", "personalRecords", "profilePreferenceSections", + ), + quotedInitializer( + """(?s)const PUSH_BODY_KEYS = new Set\(\[(.+?)\]\);""", + ), + ) + assertEquals( + listOf( + "localProfileId", "section", "documentVersion", "baseRevision", + "clientModifiedAt", "payload", + ), + quotedInitializer("""(?s)const MUTATION_KEYS = \[(.+?)\] as const;"""), + ) + assertEquals( + listOf( + "safeword", "safewordcalibrated", "adultsonlyconfirmed", + "adultsonlyprompted", "localgeneration", "dirty", "legacymigrationversion", + ), + quotedInitializer( + """(?s)const LOCAL_ONLY_KEYS = new Set\(\[(.+?)\]\);""", + ), + ) + + assertTrue("rawBodyBytes > MAX_PROFILE_PREFERENCE_REQUEST_BYTES" in code) + assertFalse("requirePostgresTextTree(rawMutation" in code) + assertFalse("console.error(\"profile preference infrastructure failure\"" in code) + assertFalse(Regex("""\buserId\s*[?:]?\s*:\s*string""").containsMatchIn(code)) + assertFalse( + Regex("""console[.](?:log|info|warn|error)[(][^)]*SERVICE_ROLE""") + .containsMatchIn(code), + ) + } + + @Test + fun `shared byte golden artifact is the exact cross-language oracle`() { + assertEquals( + exactByteGoldenArtifact, + normalizedTrackedText(byteGoldenArtifact()), + ) + } + + @Test + fun `complete normalized Edge handoff is sealed by a pinned digest`() { + val original = normalizedEdgeHandoff(edgeContract()) + val actual = edgeHandoffSha256(original) + assertEquals( + EXPECTED_EDGE_HANDOFF_SHA256, + actual, + "After writing and reviewing the exact handoff, pin this digest: $actual", + ) + assertNotEquals( + SHA256_EMPTY_RED_SENTINEL, + EXPECTED_EDGE_HANDOFF_SHA256, + "The Task 2 RED sentinel must not survive the reviewed handoff", + ) + + val appended = original + + "\n```typescript\nthrow new Error(\"appended executable mutation\");\n```\n" + val inverted = original.replaceFirst( + "rawBodyBytes > MAX_PROFILE_PREFERENCE_REQUEST_BYTES", + "rawBodyBytes <= MAX_PROFILE_PREFERENCE_REQUEST_BYTES", + ) + assertNotEquals(original, inverted, "Digest mutation target must exist") + assertNotEquals(EXPECTED_EDGE_HANDOFF_SHA256, edgeHandoffSha256(appended)) + assertNotEquals(EXPECTED_EDGE_HANDOFF_SHA256, edgeHandoffSha256(inverted)) + } + + @Test + fun `edge handoff names executable portal tests rather than prose-only assurances`() { + assertEquals( + setOf( + "database:exact-function-acls-and-no-client-dml", + "database:temporary-grant-owner-rls-and-cross-owner-user-id-protection", + "database:base-revision-accept-and-stale-canonical-conflict", + "edge:auth-and-cross-user-profile-rejection", + "edge:auth-rejection-vs-operational-outage-classification", + "edge:strict-five-section-validation-and-local-only-rejection", + "edge:kotlin-int32-float32-unicode-and-rfc3339-parity", + "edge:fatal-utf8-bom-and-original-byte-enforcement", + "edge:section-262143-262144-262145-byte-boundaries", + "edge:envelope-524287-524288-524289-byte-boundaries", + "edge:unexpected-rpc-error-is-sanitized-5xx", + "edge:same-section-concurrent-first-write", + "edge:different-section-concurrent-first-write", + "edge:lost-ack-retry-canonical-convergence", + "edge:mutation-and-first-page-pull-canonical-equality", + "edge:later-pull-pages-omit-preferences-and-keep-sync-time", + ), + portalTestManifest(), + ) + } + + @Test + fun executableEnvelopeRejectsStructuralAndPrivilegeMutations() { + val valid = sql() + val preflightMarker = "\$preflight\$" + val preflightStart = valid.indexOf("DO $preflightMarker") + val preflightEnd = + valid.indexOf("$preflightMarker;", startIndex = preflightStart) + + preflightMarker.length + + 1 + assertTrue(preflightStart >= 0 && preflightEnd > preflightStart) + val preflight = valid.substring(preflightStart, preflightEnd) + val withoutPreflight = valid.removeRange(preflightStart, preflightEnd) + val reorderedPreflight = withoutPreflight.replace( + "ALTER TABLE public.local_profile_preferences ENABLE ROW LEVEL SECURITY;", + "$preflight\n\n" + + "ALTER TABLE public.local_profile_preferences ENABLE ROW LEVEL SECURITY;", + ) + + val mutations = mapOf( + "missing BEGIN" to valid.replaceFirst("BEGIN;", ""), + "missing COMMIT" to valid.replace("COMMIT;", ""), + "duplicate target table" to valid.replace( + "ALTER TABLE public.local_profile_preferences ENABLE ROW LEVEL SECURITY;", + "CREATE TABLE public.local_profile_preferences (user_id uuid);\n\n" + + "ALTER TABLE public.local_profile_preferences ENABLE ROW LEVEL SECURITY;", + ), + "additional ALTER TABLE owner mutation" to valid.replace( + "ALTER TABLE public.local_profile_preferences ENABLE ROW LEVEL SECURITY;", + "ALTER TABLE public.local_profile_preferences ENABLE ROW LEVEL SECURITY;\n" + + "ALTER TABLE public.local_profile_preferences OWNER TO authenticated;", + ), + "role mutation" to valid.replace( + "BEGIN;", + "BEGIN;\nSET ROLE authenticated;", + ), + "dynamic DDL" to valid.replace( + "BEGIN\n IF to_regclass", + "BEGIN\n" + + " EXECUTE 'ALTER TABLE public.local_profile_preferences " + + "OWNER TO authenticated';\n" + + " IF to_regclass", + ), + "dynamic DDL after a line comment" to valid.replace( + " IF matching_key IS NULL THEN", + " -- a comment must not hide the next executable line\n" + + " EXECUTE 'ALTER TABLE public.local_profile_preferences " + + "OWNER TO authenticated';\n\n" + + " IF matching_key IS NULL THEN", + ), + "session role mutation inside preflight" to valid.replace( + " IF matching_key IS NULL THEN", + " SET SESSION ROLE authenticated;\n\n" + + " IF matching_key IS NULL THEN", + ), + "direct DML inside preflight" to valid.replace( + " IF matching_key IS NULL THEN", + " DELETE FROM public.local_profiles;\n\n" + + " IF matching_key IS NULL THEN", + ), + "indirect database call inside preflight" to valid.replace( + " IF matching_key IS NULL THEN", + " PERFORM dblink_exec('remote', 'DELETE FROM public.local_profiles');\n\n" + + " IF matching_key IS NULL THEN", + ), + "extra transaction control" to valid.replace( + "BEGIN;", + "BEGIN;\nSAVEPOINT before_profile_preferences;", + ), + "preflight after table DDL" to reorderedPreflight, + "missing parent guard" to valid.replace( + "IF to_regclass('public.local_profiles') IS NULL THEN", + "IF false THEN", + ), + "duplicate security revoke" to valid.replace( + "REVOKE ALL ON TABLE public.local_profile_preferences FROM PUBLIC;", + "REVOKE ALL ON TABLE public.local_profile_preferences FROM PUBLIC;\n" + + "REVOKE ALL ON TABLE public.local_profile_preferences FROM PUBLIC;", + ), + ) + + mutations.forEach { (name, mutation) -> + assertFailsWith("Must reject $name") { + assertSecuredSurface(mutation) + } + } + } + + @Test + fun sqlHandoffDeclaresTheExactProfilePreferenceSchema() { + val source = sql() + val statements = normalizedTopLevelStatements(source) + assertFinalExecutableEnvelope(statements) + val sql = normalizedSql(source) + val entries = tableEntries(sql) + val columns = entries.filterNot { entry -> entry.startsWith("constraint ") } + assertEquals( + listOf( + "user_id uuid not null", + "local_profile_id text not null", + "schema_version integer not null default 1 check (schema_version = 1)", + "body_weight_kg double precision not null default 0", + "weight_unit text not null default 'LB'", + "weight_increment double precision not null default -1", + "core_revision bigint not null default 0 check (core_revision >= 0)", + "core_updated_at timestamptz not null default now()", + """equipment_rack jsonb not null default '{"version":1,"items":[]}'::jsonb""", + "rack_revision bigint not null default 0 check (rack_revision >= 0)", + "rack_updated_at timestamptz not null default now()", + """workout_preferences jsonb not null default '{"version":1}'::jsonb""", + "workout_revision bigint not null default 0 check (workout_revision >= 0)", + "workout_updated_at timestamptz not null default now()", + "led_color_scheme_id integer not null default 0", + """led_preferences jsonb not null default '{"version":1,"discoModeUnlocked":false}'::jsonb""", + "led_revision bigint not null default 0 check (led_revision >= 0)", + "led_updated_at timestamptz not null default now()", + "vbt_enabled boolean not null default true", + """vbt_preferences jsonb not null default '{"version":1,"velocityLossThresholdPercent":20,"autoEndOnVelocityLoss":false,"defaultScalingBasis":"MAX_WEIGHT_PR","verbalEncouragementEnabled":false,"vulgarModeEnabled":false,"vulgarTier":"STRONG","dominatrixModeUnlocked":false,"dominatrixModeActive":false}'::jsonb""", + "vbt_revision bigint not null default 0 check (vbt_revision >= 0)", + "vbt_updated_at timestamptz not null default now()", + "updated_at timestamptz generated always as (" + + "greatest(core_updated_at, rack_updated_at, workout_updated_at, " + + "led_updated_at, vbt_updated_at)) stored", + ), + columns, + ) + + val constraintPairs = entries + .filter { entry -> entry.startsWith("constraint ") } + .map { entry -> + val match = assertNotNull( + Regex("""^constraint ([a-z_][a-z0-9_]*)\b""").find(entry), + "Every table constraint must be explicitly named", + ) + match.groupValues[1] to entry + } + val constraints = constraintPairs.toMap() + assertEquals(constraintPairs.size, constraints.size, "Constraint names must be unique") + + val expectedConstraints = mapOf( + "local_profile_preferences_pkey" to + "constraint local_profile_preferences_pkey " + + "primary key (user_id, local_profile_id)", + "local_profile_preferences_parent_fkey" to + "constraint local_profile_preferences_parent_fkey " + + "foreign key (user_id, local_profile_id) " + + "references public.local_profiles(user_id, id) on delete cascade", + "local_profile_preferences_body_weight_check" to + "constraint local_profile_preferences_body_weight_check check (" + + "body_weight_kg not in (" + + "'NaN'::double precision, 'Infinity'::double precision, " + + "'-Infinity'::double precision) and " + + "(body_weight_kg = 0 or body_weight_kg between 20 and 300))", + "local_profile_preferences_weight_unit_check" to + "constraint local_profile_preferences_weight_unit_check " + + "check (weight_unit in ('KG', 'LB'))", + "local_profile_preferences_weight_increment_check" to + "constraint local_profile_preferences_weight_increment_check check (" + + "weight_increment not in (" + + "'NaN'::double precision, 'Infinity'::double precision, " + + "'-Infinity'::double precision) and " + + "(weight_increment = -1 or weight_increment > 0))", + "local_profile_preferences_led_scheme_check" to + "constraint local_profile_preferences_led_scheme_check " + + "check (led_color_scheme_id >= 0)", + "local_profile_preferences_rack_object_check" to + "constraint local_profile_preferences_rack_object_check check (" + + "jsonb_typeof(equipment_rack) = 'object' and " + + """equipment_rack @> '{"version":1}'::jsonb and """ + + "jsonb_typeof(equipment_rack -> 'items') = 'array' and " + + "not jsonb_path_exists(" + + "equipment_rack, '$.items[*] ? (@.weightKg < 0)') and " + + "octet_length(equipment_rack::text) <= 262144)", + "local_profile_preferences_workout_object_check" to + "constraint local_profile_preferences_workout_object_check check (" + + "jsonb_typeof(workout_preferences) = 'object' and " + + """workout_preferences @> '{"version":1}'::jsonb and """ + + "(not workout_preferences ? 'summaryCountdownSeconds' or (" + + "jsonb_typeof(workout_preferences -> 'summaryCountdownSeconds') = " + + "'number' and " + + "(workout_preferences ->> 'summaryCountdownSeconds')::integer " + + "in (-1, 0, 5, 10, 15, 20, 25, 30))) and " + + "(not workout_preferences ? 'autoStartCountdownSeconds' or (" + + "jsonb_typeof(workout_preferences -> 'autoStartCountdownSeconds') = " + + "'number' and " + + "(workout_preferences ->> 'autoStartCountdownSeconds')::integer " + + "between 2 and 10)) and " + + "(not workout_preferences ? " + + "'defaultRoutineExerciseWeightPercentOfPR' or (" + + "jsonb_typeof(workout_preferences -> " + + "'defaultRoutineExerciseWeightPercentOfPR') = 'number' and " + + "(workout_preferences ->> " + + "'defaultRoutineExerciseWeightPercentOfPR')::integer " + + "between 50 and 120)) and " + + "octet_length(workout_preferences::text) <= 262144)", + "local_profile_preferences_led_object_check" to + "constraint local_profile_preferences_led_object_check check (" + + "jsonb_typeof(led_preferences) = 'object' and " + + """led_preferences @> '{"version":1}'::jsonb and """ + + "jsonb_typeof(led_preferences -> 'discoModeUnlocked') = " + + "'boolean' and octet_length(led_preferences::text) <= 262144)", + "local_profile_preferences_vbt_object_check" to + "constraint local_profile_preferences_vbt_object_check check (" + + "jsonb_typeof(vbt_preferences) = 'object' and " + + """vbt_preferences @> '{"version":1}'::jsonb and """ + + "jsonb_typeof(vbt_preferences -> " + + "'velocityLossThresholdPercent') = 'number' and " + + "(vbt_preferences ->> 'velocityLossThresholdPercent')::integer " + + "between 10 and 50 and " + + "octet_length(vbt_preferences::text) <= 262144)", + ) + assertEquals(expectedConstraints, constraints) + + assertEquals( + "updated_at timestamptz generated always as (" + + "greatest(core_updated_at, rack_updated_at, workout_updated_at, " + + "led_updated_at, vbt_updated_at)) stored", + columns.single { column -> column.startsWith("updated_at ") }, + ) + + val aggregate = "array_agg(attribute.attname::text order by key_column.ordinality)" + assertEquals(2, Regex(Regex.escape(aggregate)).findAll(sql).count()) + assertTrue(sql.contains("select " + aggregate + " into matching_key")) + assertTrue( + sql.contains( + "having " + aggregate + " = array['user_id', 'id']::text[]", + ), + ) + + val workoutConstraint = constraints.getValue( + "local_profile_preferences_workout_object_check", + ) + val countdownSets = Regex( + """[(]workout_preferences ->> 'summaryCountdownSeconds'[)]::integer """ + + """in [(]([^)]*)[)]""", + ).findAll(workoutConstraint).toList() + assertEquals(1, countdownSets.size) + assertEquals("-1, 0, 5, 10, 15, 20, 25, 30", countdownSets.single().groupValues[1]) + + mapOf( + "local_profile_preferences_rack_object_check" to + "octet_length(equipment_rack::text) <= 262144", + "local_profile_preferences_workout_object_check" to + "octet_length(workout_preferences::text) <= 262144", + "local_profile_preferences_led_object_check" to + "octet_length(led_preferences::text) <= 262144", + "local_profile_preferences_vbt_object_check" to + "octet_length(vbt_preferences::text) <= 262144", + ).forEach { (name, ceiling) -> + assertTrue(constraints.getValue(name).contains(ceiling)) + } + } + + @Test + fun sqlHandoffSecuresTheExactProfilePreferenceSurface() { + assertSecuredSurface(sql()) + } + + private fun assertSecuredSurface(value: String) { + val statements = normalizedTopLevelStatements(value) + assertFinalExecutableEnvelope(statements) + val sql = statements.joinToString(separator = "; ", postfix = ";") + assertTrue( + statements.contains( + "alter table public.local_profile_preferences enable row level security", + ), + ) + assertFalse( + Regex("""\bdisable\s+row\s+level\s+security\b""").containsMatchIn(sql), + "The handoff must never disable RLS", + ) + + val expectedPolicies = mapOf( + "local_profile_preferences_owner_select" to + "create policy local_profile_preferences_owner_select " + + "on public.local_profile_preferences for select to authenticated " + + "using ((select auth.uid()) = user_id)", + "local_profile_preferences_owner_insert" to + "create policy local_profile_preferences_owner_insert " + + "on public.local_profile_preferences for insert to authenticated " + + "with check ((select auth.uid()) = user_id)", + "local_profile_preferences_owner_update" to + "create policy local_profile_preferences_owner_update " + + "on public.local_profile_preferences for update to authenticated " + + "using ((select auth.uid()) = user_id) " + + "with check ((select auth.uid()) = user_id)", + "local_profile_preferences_owner_delete" to + "create policy local_profile_preferences_owner_delete " + + "on public.local_profile_preferences for delete to authenticated " + + "using ((select auth.uid()) = user_id)", + ) + val createPolicyPattern = Regex("""^create\s+policy\b""") + val policyStatements = statements.filter { statement -> + createPolicyPattern.containsMatchIn(statement) + } + assertEquals(expectedPolicies.size, policyStatements.size) + assertEquals(expectedPolicies.values.toSet(), policyStatements.toSet()) + assertEquals( + expectedPolicies.keys, + policyStatements + .map { statement -> + statement.removePrefix("create policy ").substringBefore(" on ") + } + .toSet(), + ) + + val forbiddenPolicyDdl = Regex("""^(?:alter|drop)\s+policy\b""") + assertFalse( + statements.any { statement -> forbiddenPolicyDdl.containsMatchIn(statement) }, + "ALTER POLICY and DROP POLICY are forbidden in the handoff", + ) + + assertTrue("revoke all on table public.local_profile_preferences from public" in statements) + assertTrue("revoke all on table public.local_profile_preferences from anon" in statements) + assertTrue( + "revoke all on table public.local_profile_preferences from authenticated" in statements, + ) + + val grantPattern = Regex( + """^grant\s+(.+?)\s+on\s+(?:table\s+)?(.+?)\s+to\s+(.+)$""", + ) + val grantStatements = statements.filter { statement -> + statement.startsWith("grant ") + } + val expectedTableGrant = + "grant select, insert, update, delete on table " + + "public.local_profile_preferences to service_role" + assertEquals( + exactFunctionAclStatements().filter { statement -> + statement.startsWith("grant ") + } + expectedTableGrant, + grantStatements, + "Only the exact service-role function and table grants are allowed", + ) + + val tableGrants = grantStatements.mapNotNull { statement -> + grantPattern.matchEntire(statement) + }.filter { grant -> + grant.groupValues[2].split(',').any { target -> + target.trim() == "public.local_profile_preferences" + } + } + val forbiddenClientRole = Regex("""\b(?:public|anon|authenticated)\b""") + assertFalse( + tableGrants.any { grant -> + forbiddenClientRole.containsMatchIn(grant.groupValues[3]) + }, + "Client roles must not receive direct table grants", + ) + assertEquals(1, tableGrants.size, "Only the service-role table grant is allowed") + assertEquals( + "select, insert, update, delete", + tableGrants.single().groupValues[1].trim(), + ) + assertEquals("public.local_profile_preferences", tableGrants.single().groupValues[2].trim()) + assertEquals("service_role", tableGrants.single().groupValues[3].trim()) + + val forbiddenLocalFieldPatterns = mapOf( + "local safety or consent" to Regex( + """(?i)\b[a-z0-9_]*(?:local_?safety|safe_?word(?:_?(?:calibrated|calibration))?|""" + + """adults?_?only_?(?:confirmed|prompted|consent)|adult_?consent)\b""", + ), + "local generation" to Regex("""(?i)\b[a-z0-9_]*local_?generation\b"""), + "dirty state" to Regex("""(?i)\b[a-z0-9_]*dirty\b"""), + "legacy migration version" to + Regex( + """(?i)\b[a-z0-9_]*(?:legacy_migration_version|""" + + """legacymigrationversion)\b""", + ), + ) + forbiddenLocalFieldPatterns.forEach { (field, pattern) -> + assertFalse( + pattern.containsMatchIn(sql), + "Local-only field must not enter backend SQL: $field", + ) + } + } +} diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/ErrorClassificationTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/ErrorClassificationTest.kt index 5f0b44a31..01045ef2b 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/ErrorClassificationTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/ErrorClassificationTest.kt @@ -81,6 +81,15 @@ class ErrorClassificationTest { assertEquals(404, result.statusCode) } + @Test + fun status413IsPermanentAndNotRetryable() { + val result = classifyByStatusCode(413, "Payload too large") + + assertEquals(SyncErrorCategory.PERMANENT, result.category) + assertFalse(result.isRetryable) + assertEquals(413, result.statusCode) + } + @Test fun status429IsTransientAndRetryable() { val result = classifyByStatusCode(429, "Too many requests") diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/PortalApiClientProfilePreferenceLimitsTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/PortalApiClientProfilePreferenceLimitsTest.kt new file mode 100644 index 000000000..7b90f4521 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/PortalApiClientProfilePreferenceLimitsTest.kt @@ -0,0 +1,344 @@ +package com.devil.phoenixproject.data.sync + +import com.devil.phoenixproject.testutil.readProjectFile +import com.russhwolf.settings.MapSettings +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.request.HttpRequestData +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.content.OutgoingContent +import io.ktor.http.headersOf +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +private const val LEGACY_PUSH_RESPONSE = + """{"syncTime":"2026-07-11T12:00:00Z"}""" +private val JSON_RESPONSE_HEADERS = headersOf( + HttpHeaders.ContentType, + ContentType.Application.Json.toString(), +) + +private fun runPortalHttpTest(block: suspend () -> Unit) = runTest { + withContext(Dispatchers.Default) { block() } +} + +class PortalApiClientProfilePreferenceLimitsTest { + private fun authenticatedStorage( + accessToken: String = "old-token", + refreshToken: String = "refresh-token", + ) = PortalTokenStorage(MapSettings()).apply { + saveGoTrueAuth( + GoTrueAuthResponse( + accessToken = accessToken, + tokenType = "bearer", + expiresIn = 3600, + expiresAt = 4_102_444_800L, + refreshToken = refreshToken, + user = GoTrueUser(id = "user-a", email = "user@example.com"), + ), + ) + } + + private fun client(engine: MockEngine, storage: PortalTokenStorage = authenticatedStorage()) = + PortalApiClient( + SupabaseConfig("https://fake.supabase.co", "anon"), + storage, + httpClientEngine = engine, + ) + + private fun mutation(index: Int = 0, padding: Int = 0) = + PortalProfilePreferenceSectionMutationDto( + localProfileId = "profile-$index", + section = "RACK", + documentVersion = 1, + baseRevision = 0, + clientModifiedAt = "2026-07-11T12:00:00Z", + payload = buildJsonObject { put("padding", "x".repeat(padding)) }, + ) + + private fun preferencePayload( + mutations: List, + ) = PortalSyncPayload( + deviceId = "device", + platform = "android", + lastSync = 0, + profilePreferenceSections = mutations, + ) + + private fun payloadWithSectionBytes(targetBytes: Int): PortalSyncPayload { + val seed = preferencePayload(listOf(mutation())) + val encodedSeed = encodePortalSyncPayload(seed) + val fixedBytes = encodedSeed.preferenceElementByteCount( + encodedSeed.preferenceElementSpans.single(), + ) + val payload = preferencePayload( + listOf(mutation(padding = targetBytes - fixedBytes)), + ) + val encoded = encodePortalSyncPayload(payload) + check( + encoded.preferenceElementByteCount(encoded.preferenceElementSpans.single()) == + targetBytes, + ) + return payload + } + + private fun preferencePayloadWithRequestBytes(targetBytes: Int): PortalSyncPayload { + val seeds = List(3) { mutation(index = it) } + val fixedBytes = encodePortalSyncPayload(preferencePayload(seeds)).rawBytes.size + val paddingBytes = targetBytes - fixedBytes + require(paddingBytes >= 0) + val payload = preferencePayload( + seeds.mapIndexed { index, _ -> + val padding = paddingBytes / seeds.size + + if (index < paddingBytes % seeds.size) 1 else 0 + mutation(index = index, padding = padding) + }, + ) + check(encodePortalSyncPayload(payload).rawBytes.size == targetBytes) + return payload + } + + private fun payloadWithRequestBytes( + targetBytes: Int, + sections: List?, + ): PortalSyncPayload { + val seed = PortalSyncPayload( + deviceId = "", + lastSync = 0, + profilePreferenceSections = sections, + ) + val paddingBytes = targetBytes - encodePortalSyncPayload(seed).rawBytes.size + require(paddingBytes >= 0) + val payload = seed.copy(deviceId = "x".repeat(paddingBytes)) + check(encodePortalSyncPayload(payload).rawBytes.size == targetBytes) + return payload + } + + private fun requestBodyBytes(request: HttpRequestData): ByteArray = + assertIs(request.body).bytes() + + private fun effectiveContentTypes(request: HttpRequestData): List = + request.headers.getAll(HttpHeaders.ContentType).orEmpty().map(ContentType::parse) + + listOfNotNull(request.body.contentType) + + @Test + fun `transport writes the exact counted preference bytes with one JSON content type`() = + runPortalHttpTest { + val requests = mutableListOf() + val engine = MockEngine { request -> + requests += request + respond(LEGACY_PUSH_RESPONSE, HttpStatusCode.OK, JSON_RESPONSE_HEADERS) + } + val payload = preferencePayload(listOf(mutation(padding = 64))) + val expectedBytes = encodePortalSyncPayload(payload).rawBytes + + val result = client(engine).pushPortalPayload(payload) + + assertTrue(result.isSuccess) + val request = requests.single() + assertContentEquals(expectedBytes, requestBodyBytes(request)) + val contentTypes = effectiveContentTypes(request) + assertEquals(1, contentTypes.size) + assertTrue(contentTypes.single().match(ContentType.Application.Json)) + assertEquals("Bearer old-token", request.headers[HttpHeaders.Authorization]) + assertEquals("anon", request.headers["apikey"]) + } + + @Test + fun `shared ContentNegotiation decodes an ordinary legacy push response`() = + runPortalHttpTest { + val engine = MockEngine { + respond(LEGACY_PUSH_RESPONSE, HttpStatusCode.OK, JSON_RESPONSE_HEADERS) + } + + val response = client(engine) + .pushPortalPayload(PortalSyncPayload(deviceId = "device", lastSync = 0)) + .getOrThrow() + + assertNull(response.profilePreferencesAccepted) + assertTrue(response.canonicalProfilePreferenceSections.isEmpty()) + assertTrue(response.profilePreferenceRejections.isEmpty()) + } + + @Test + fun `401 refresh retry writes byte-identical push bodies`() = + runPortalHttpTest { + val pushRequests = mutableListOf() + val engine = MockEngine { request -> + when { + request.url.encodedPath.endsWith("/functions/v1/mobile-sync-push") -> { + pushRequests += request + if (pushRequests.size == 1) { + respond( + """{"error":"expired"}""", + HttpStatusCode.Unauthorized, + JSON_RESPONSE_HEADERS, + ) + } else { + respond(LEGACY_PUSH_RESPONSE, HttpStatusCode.OK, JSON_RESPONSE_HEADERS) + } + } + request.url.encodedPath.endsWith("/auth/v1/token") -> respond( + """ + { + "access_token":"new-token", + "token_type":"bearer", + "expires_in":3600, + "expires_at":4102444800, + "refresh_token":"new-refresh", + "user":{"id":"user-a","email":"user@example.com"} + } + """.trimIndent(), + HttpStatusCode.OK, + JSON_RESPONSE_HEADERS, + ) + else -> error("Unexpected MockEngine path") + } + } + val payload = preferencePayload(listOf(mutation(padding = 128))) + val expectedBytes = encodePortalSyncPayload(payload).rawBytes + + val result = client(engine).pushPortalPayload(payload) + assertTrue(result.isSuccess, result.exceptionOrNull()?.toString()) + + assertEquals(2, pushRequests.size) + pushRequests.forEach { request -> + assertContentEquals(expectedBytes, requestBodyBytes(request)) + } + assertEquals( + listOf("Bearer old-token", "Bearer new-token"), + pushRequests.map { it.headers[HttpHeaders.Authorization] }, + ) + } + + @Test + fun `section cap accepts 262144 and rejects 262145 before HTTP`() = + runPortalHttpTest { + var httpCalls = 0 + val engine = MockEngine { + httpCalls++ + respond(LEGACY_PUSH_RESPONSE, HttpStatusCode.OK, JSON_RESPONSE_HEADERS) + } + val client = client(engine) + + assertTrue( + client.pushPortalPayload( + payloadWithSectionBytes(MAX_PROFILE_PREFERENCE_SECTION_BYTES), + ).isSuccess, + ) + val error = client.pushPortalPayload( + payloadWithSectionBytes(MAX_PROFILE_PREFERENCE_SECTION_BYTES + 1), + ).exceptionOrNull() + + assertIs(error) + assertEquals(413, error.statusCode) + assertEquals("SECTION_TOO_LARGE: cap=262144", error.message) + assertEquals(1, httpCalls) + } + + @Test + fun `request cap accepts 524288 and rejects 524289 before HTTP`() = + runPortalHttpTest { + var httpCalls = 0 + val engine = MockEngine { + httpCalls++ + respond(LEGACY_PUSH_RESPONSE, HttpStatusCode.OK, JSON_RESPONSE_HEADERS) + } + val client = client(engine) + val exact = preferencePayloadWithRequestBytes(MAX_PROFILE_PREFERENCE_REQUEST_BYTES) + val overflow = preferencePayloadWithRequestBytes( + MAX_PROFILE_PREFERENCE_REQUEST_BYTES + 1, + ) + assertTrue( + encodePortalSyncPayload(exact).preferenceElementSpans.all { span -> + encodePortalSyncPayload(exact).preferenceElementByteCount(span) <= + MAX_PROFILE_PREFERENCE_SECTION_BYTES + }, + ) + + val exactResult = client.pushPortalPayload(exact) + assertTrue(exactResult.isSuccess, exactResult.exceptionOrNull()?.toString()) + val error = client.pushPortalPayload(overflow).exceptionOrNull() + + assertIs(error) + assertEquals(413, error.statusCode) + assertEquals("REQUEST_TOO_LARGE: cap=524288", error.message) + assertEquals(1, httpCalls) + } + + @Test + fun `empty preference list uses 512 KiB while null preserves legacy ordinary cap`() = + runPortalHttpTest { + var httpCalls = 0 + val engine = MockEngine { + httpCalls++ + respond(LEGACY_PUSH_RESPONSE, HttpStatusCode.OK, JSON_RESPONSE_HEADERS) + } + val client = client(engine) + val target = MAX_PROFILE_PREFERENCE_REQUEST_BYTES + 1 + val ordinary = payloadWithRequestBytes(target, sections = null) + val presentEmpty = payloadWithRequestBytes(target, sections = emptyList()) + + assertTrue(client.pushPortalPayload(ordinary).isSuccess) + val error = client.pushPortalPayload(presentEmpty).exceptionOrNull() + + assertIs(error) + assertEquals(413, error.statusCode) + assertEquals("REQUEST_TOO_LARGE: cap=524288", error.message) + assertEquals(1, httpCalls) + } + + @Test + fun `legacy 9500000 byte cap is inclusive and overflow is 413`() = + runPortalHttpTest { + var httpCalls = 0 + val engine = MockEngine { + httpCalls++ + respond(LEGACY_PUSH_RESPONSE, HttpStatusCode.OK, JSON_RESPONSE_HEADERS) + } + val client = client(engine) + val exact = payloadWithRequestBytes(SyncConfig.MAX_PAYLOAD_BYTES.toInt(), null) + val overflow = payloadWithRequestBytes( + SyncConfig.MAX_PAYLOAD_BYTES.toInt() + 1, + null, + ) + + assertTrue(client.pushPortalPayload(exact).isSuccess) + val error = client.pushPortalPayload(overflow).exceptionOrNull() + + assertIs(error) + assertEquals(413, error.statusCode) + assertEquals(1, httpCalls) + } + + @Test + fun `transport source encodes once per logical push and sends only raw bytes`() { + val source = assertNotNull( + readProjectFile( + "src/commonMain/kotlin/com/devil/phoenixproject/data/sync/PortalApiClient.kt", + ), + ) + + assertTrue("json(PortalWireJson)" in source) + assertEquals( + 1, + Regex("""encodePortalSyncPayload[(]payload[)]""").findAll(source).count(), + ) + assertTrue("setBody(encoded.rawBytes)" in source) + assertFalse(Regex("""setBody[(]encoded[.]raw[)]""").containsMatchIn(source)) + assertFalse(Regex("""private\s+val\s+json\s*=\s*Json""").containsMatchIn(source)) + } +} diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/PortalPullAdapterProfilePreferencesTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/PortalPullAdapterProfilePreferencesTest.kt new file mode 100644 index 000000000..353552609 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/PortalPullAdapterProfilePreferencesTest.kt @@ -0,0 +1,138 @@ +package com.devil.phoenixproject.data.sync + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject + +class PortalPullAdapterProfilePreferencesTest { + @Test + fun `pull adapter rejects document version mismatch without fallback`() { + val wire = PortalProfilePreferenceSectionCanonicalDto( + localProfileId = "profile-a", + section = "VBT", + documentVersion = 2, + serverRevision = 3, + serverUpdatedAt = "2026-07-11T12:00:00.000Z", + payload = buildJsonObject { + put("vbtEnabled", true) + putJsonObject("preferences") { put("version", 1) } + }, + ) + + val invalid = assertIs( + PortalPullAdapter.toCanonicalProfilePreferenceSection(wire), + ) + assertEquals(ProfilePreferenceSyncIssueReason.UNSUPPORTED_DOCUMENT_VERSION.name, invalid.reason) + } + + @Test + fun `pull adapter enforces exact canonical revision interval`() { + assertIs( + PortalPullAdapter.toCanonicalProfilePreferenceSection( + coreCanonicalWire(revision = 9_007_199_254_740_991L), + ), + ) + + listOf(-1L, 9_007_199_254_740_992L).forEach { revision -> + val invalid = assertIs( + PortalPullAdapter.toCanonicalProfilePreferenceSection( + coreCanonicalWire(revision = revision), + ), + ) + assertEquals( + ProfilePreferenceSyncIssueReason.INVALID_CANONICAL_REVISION.name, + invalid.reason, + ) + } + } + + @Test + fun `pull adapter rejects wrapper identity timestamp and section with category only`() { + val sentinel = "SECRET_REMOTE_SENTINEL" + val cases = listOf( + coreCanonicalWire(localProfileId = " ") to + ProfilePreferenceSyncIssueReason.INVALID_PROFILE_ID, + coreCanonicalWire(localProfileId = "$sentinel\u0000") to + ProfilePreferenceSyncIssueReason.INVALID_PROFILE_ID, + coreCanonicalWire(serverUpdatedAt = "+10000-01-01T00:00:00.000Z") to + ProfilePreferenceSyncIssueReason.INVALID_CANONICAL_TIMESTAMP, + coreCanonicalWire().copy(section = sentinel) to + ProfilePreferenceSyncIssueReason.UNSUPPORTED_SECTION, + ) + + cases.forEach { (wire, expectedReason) -> + val invalid = assertIs( + PortalPullAdapter.toCanonicalProfilePreferenceSection(wire), + ) + assertEquals(expectedReason.name, invalid.reason) + assertFalse(sentinel in invalid.reason) + } + } + + @Test + fun `pull adapter rejects non contract timestamp spellings and calendar values`() { + listOf( + "+002026-07-11T12:00:00Z", + "+000001-01-01T00:00:00Z", + "2026-07-11t12:00:00Z", + "2026-07-11T12:00:00z", + "2026-07-11T12:00:00+00", + "2025-02-29T12:00:00Z", + "2026-07-11T24:00:00Z", + "2026-07-11T12:00:00+24:00", + "2026-07-11T12:00:00.1234567890Z", + ).forEach { timestamp -> + val invalid = assertIs( + PortalPullAdapter.toCanonicalProfilePreferenceSection( + coreCanonicalWire(serverUpdatedAt = timestamp), + ), + ) + assertEquals( + ProfilePreferenceSyncIssueReason.INVALID_CANONICAL_TIMESTAMP.name, + invalid.reason, + ) + assertFalse(timestamp in invalid.reason) + } + } + + @Test + fun `pull adapter accepts canonical Edge timestamp forms`() { + listOf( + "0001-01-01T00:00:00Z" to -62_135_596_800_000L, + "2026-07-11T12:00:00Z" to 1_783_771_200_000L, + "2026-07-11T12:00:00.1Z" to 1_783_771_200_100L, + "2026-07-11T14:30:00.123456789+02:30" to 1_783_771_200_123L, + "2026-07-11T12:00:00+23:59" to 1_783_684_860_000L, + "2026-07-11T12:00:00-23:59" to 1_783_857_540_000L, + "9999-12-31T23:59:59.999Z" to 253_402_300_799_999L, + ).forEach { (timestamp, expectedEpochMs) -> + val valid = assertIs( + PortalPullAdapter.toCanonicalProfilePreferenceSection( + coreCanonicalWire(serverUpdatedAt = timestamp), + ), + ) + assertEquals(expectedEpochMs, valid.section.serverUpdatedAtEpochMs) + } + } + + private fun coreCanonicalWire( + revision: Long = 3, + localProfileId: String = "profile-a", + serverUpdatedAt: String = "2026-07-11T12:00:00.000Z", + ) = PortalProfilePreferenceSectionCanonicalDto( + localProfileId = localProfileId, + section = "CORE", + documentVersion = 1, + serverRevision = revision, + serverUpdatedAt = serverUpdatedAt, + payload = buildJsonObject { + put("bodyWeightKg", 82.5) + put("weightUnit", "KG") + put("weightIncrement", 0.5) + }, + ) +} diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/PortalPullPaginationTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/PortalPullPaginationTest.kt index 6481173d8..4546dacb9 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/PortalPullPaginationTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/PortalPullPaginationTest.kt @@ -3,6 +3,7 @@ package com.devil.phoenixproject.data.sync import com.devil.phoenixproject.testutil.FakeExternalActivityRepository import com.devil.phoenixproject.testutil.FakeGamificationRepository import com.devil.phoenixproject.testutil.FakePortalApiClient +import com.devil.phoenixproject.testutil.FakeProfilePreferenceSyncRepository import com.devil.phoenixproject.testutil.FakeRepMetricRepository import com.devil.phoenixproject.testutil.FakeSyncRepository import com.devil.phoenixproject.testutil.FakeUserProfileRepository @@ -16,6 +17,8 @@ import kotlin.test.assertNull import kotlin.test.assertTrue import kotlinx.coroutines.test.currentTime import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put /** * Tests covering the pull-side pagination contract: @@ -45,6 +48,7 @@ class PortalPullPaginationTest { private val fakeUserProfileRepo = FakeUserProfileRepository() private val fakeExternalActivityRepo = FakeExternalActivityRepository() private val fakeVelocityRepo = FakeVelocityOneRepMaxRepository() + private val fakeProfilePreferenceSyncRepo = FakeProfilePreferenceSyncRepository() private fun createManager(rateLimiter: ClientRateLimiter = ClientRateLimiter()) = SyncManager( apiClient = fakeApi, @@ -53,9 +57,11 @@ class PortalPullPaginationTest { gamificationRepository = fakeGamificationRepo, repMetricRepository = fakeRepMetricRepo, userProfileRepository = fakeUserProfileRepo, + profilePreferenceSyncRepository = fakeProfilePreferenceSyncRepo, externalActivityRepository = fakeExternalActivityRepo, velocityOneRepMaxRepository = fakeVelocityRepo, rateLimiter = rateLimiter, + isProfilePreferenceMigrationReady = { true }, ) private fun authenticate(userId: String = "user-123") { @@ -117,11 +123,11 @@ class PortalPullPaginationTest { assertEquals(3, fakeApi.pullCallCount, "Pull should fire once per page") // Across 3 pages, 300 unique sessions should have been merged. // Each page triggers mergeAllPullData with that page's slice; we check the final merge - // call contains page 3's payload, and the total count of merged sessions via atomic merges. + // call contains page 3's payload, and every page reaches the ordinary repository merge. assertEquals( 3, fakeSyncRepo.atomicMergeCallCount, - "Each page calls mergeAllPullData once (atomic per-page merge)", + "Each page calls mergeAllPullData once", ) } @@ -228,7 +234,7 @@ class PortalPullPaginationTest { assertTrue(result.isSuccess, "empty response → success, no crash") // SyncManager calls mergeAllPullData exactly once with all-empty lists; the fake's // counter-based tracking (which only increments per non-empty list) stays at 0, but - // the atomicMergeCallCount (invoked unconditionally) is 1. + // the ordinary merge call counter (invoked unconditionally) is 1. assertEquals( 1, fakeSyncRepo.atomicMergeCallCount, @@ -560,6 +566,83 @@ class PortalPullPaginationTest { assertEquals(fakeUuid(totalSessionIds - 1), known.sessionIds.last()) } + @Test + fun preferenceOnlyPageCountsAsNonEmptyAndContinuesPagination() = runTest { + authenticate() + fakeApi.pullResultsQueue = mutableListOf( + Result.success( + PortalSyncPullResponse( + syncTime = 1_783_771_200_000L, + hasMore = true, + nextCursor = "page-2", + profilePreferenceSections = listOf(coreCanonicalForPull(revision = 2)), + ), + ), + Result.success( + PortalSyncPullResponse( + syncTime = 1_783_771_201_000L, + hasMore = false, + ), + ), + ) + + val result = createManager().sync() + + assertTrue(result.isSuccess) + assertEquals(2, fakeApi.pullCallCount) + assertEquals(listOf(null, "page-2"), fakeApi.pullCallCursors) + } + + @Test + fun laterPagePreferenceIsIgnoredButStillKeepsPaginationMoving() = runTest { + authenticate() + fakeApi.pullResultsQueue = mutableListOf( + Result.success( + PortalSyncPullResponse( + syncTime = 1_783_771_200_000L, + hasMore = true, + nextCursor = "page-2", + routines = listOf(PullRoutineDto(id = "routine-1", name = "Routine 1")), + ), + ), + Result.success( + PortalSyncPullResponse( + syncTime = 1_783_771_201_000L, + hasMore = true, + nextCursor = "page-3", + profilePreferenceSections = listOf(coreCanonicalForPull(revision = 9)), + ), + ), + Result.success( + PortalSyncPullResponse( + syncTime = 1_783_771_202_000L, + routines = listOf(PullRoutineDto(id = "routine-3", name = "Routine 3")), + ), + ), + ) + + assertTrue(createManager().sync().isSuccess) + + assertEquals(3, fakeApi.pullCallCount) + assertEquals(listOf(null, "page-2", "page-3"), fakeApi.pullCallCursors) + assertEquals(0, fakeProfilePreferenceSyncRepo.pullApplyCallCount) + assertEquals(3, fakeSyncRepo.atomicMergeCallCount) + } + + private fun coreCanonicalForPull(revision: Long) = + PortalProfilePreferenceSectionCanonicalDto( + localProfileId = "profile-a", + section = "CORE", + documentVersion = 1, + serverRevision = revision, + serverUpdatedAt = "2026-07-11T12:00:00Z", + payload = buildJsonObject { + put("bodyWeightKg", 80.0) + put("weightUnit", "KG") + put("weightIncrement", 0.5) + }, + ) + // ==================== pageSize Wiring ==================== @Test diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/PortalPushLimitsTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/PortalPushLimitsTest.kt index 604781079..5e64e40ae 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/PortalPushLimitsTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/PortalPushLimitsTest.kt @@ -2,11 +2,13 @@ package com.devil.phoenixproject.data.sync import com.devil.phoenixproject.domain.model.CycleDay import com.devil.phoenixproject.domain.model.RepMetricData +import com.devil.phoenixproject.domain.model.ProfilePreferenceSectionName import com.devil.phoenixproject.domain.model.TrainingCycle import com.devil.phoenixproject.domain.model.WorkoutSession import com.devil.phoenixproject.testutil.FakeExternalActivityRepository import com.devil.phoenixproject.testutil.FakeGamificationRepository import com.devil.phoenixproject.testutil.FakePortalApiClient +import com.devil.phoenixproject.testutil.FakeProfilePreferenceSyncRepository import com.devil.phoenixproject.testutil.FakeRepMetricRepository import com.devil.phoenixproject.testutil.FakeSyncRepository import com.devil.phoenixproject.testutil.FakeUserProfileRepository @@ -18,6 +20,8 @@ import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertTrue import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put /** * Tests for push-side batch splitting defined by SyncManager.SYNC_BATCH_SIZE (=50). @@ -43,16 +47,22 @@ class PortalPushLimitsTest { private val fakeUserProfileRepo = FakeUserProfileRepository() private val fakeExternalActivityRepo = FakeExternalActivityRepository() private val fakeVelocityRepo = FakeVelocityOneRepMaxRepository() + private val fakeProfilePreferenceSyncRepo = FakeProfilePreferenceSyncRepository() - private fun createManager() = SyncManager( + private fun createManager( + rateLimiter: ClientRateLimiter = ClientRateLimiter(), + ) = SyncManager( apiClient = fakeApi, tokenStorage = tokenStorage, syncRepository = fakeSyncRepo, gamificationRepository = fakeGamificationRepo, repMetricRepository = fakeRepMetricRepo, userProfileRepository = fakeUserProfileRepo, + profilePreferenceSyncRepository = fakeProfilePreferenceSyncRepo, externalActivityRepository = fakeExternalActivityRepo, velocityOneRepMaxRepository = fakeVelocityRepo, + rateLimiter = rateLimiter, + isProfilePreferenceMigrationReady = { true }, ) private fun authenticate(userId: String = "user-123") { @@ -222,8 +232,10 @@ class PortalPushLimitsTest { gamificationRepository = fakeGamificationRepo, repMetricRepository = fakeRepMetricRepo, userProfileRepository = fakeUserProfileRepo, + profilePreferenceSyncRepository = fakeProfilePreferenceSyncRepo, externalActivityRepository = fakeExternalActivityRepo, velocityOneRepMaxRepository = fakeVelocityRepo, + isProfilePreferenceMigrationReady = { true }, ) mgr.sync() @@ -256,8 +268,10 @@ class PortalPushLimitsTest { gamificationRepository = fakeGamificationRepo, repMetricRepository = fakeRepMetricRepo, userProfileRepository = fakeUserProfileRepo, + profilePreferenceSyncRepository = fakeProfilePreferenceSyncRepo, externalActivityRepository = fakeExternalActivityRepo, velocityOneRepMaxRepository = fakeVelocityRepo, + isProfilePreferenceMigrationReady = { true }, ) mgr.sync() @@ -326,8 +340,10 @@ class PortalPushLimitsTest { gamificationRepository = fakeGamificationRepo, repMetricRepository = fakeRepMetricRepo, userProfileRepository = fakeUserProfileRepo, + profilePreferenceSyncRepository = fakeProfilePreferenceSyncRepo, externalActivityRepository = fakeExternalActivityRepo, velocityOneRepMaxRepository = fakeVelocityRepo, + isProfilePreferenceMigrationReady = { true }, ) val result = mgr.sync() @@ -364,8 +380,10 @@ class PortalPushLimitsTest { gamificationRepository = fakeGamificationRepo, repMetricRepository = fakeRepMetricRepo, userProfileRepository = fakeUserProfileRepo, + profilePreferenceSyncRepository = fakeProfilePreferenceSyncRepo, externalActivityRepository = fakeExternalActivityRepo, velocityOneRepMaxRepository = fakeVelocityRepo, + isProfilePreferenceMigrationReady = { true }, ) mgr.sync() @@ -377,6 +395,82 @@ class PortalPushLimitsTest { ) } + @Test + fun preferenceChunksFollowTheFinalMetadataBatch() = runTest { + authenticate() + fakeUserProfileRepo.setActiveProfileForTest("profile-a") + fakeSyncRepo.workoutSessionsToReturn = buildSessions(73) + fakeProfilePreferenceSyncRepo.dirtySnapshot = ProfilePreferenceDirtySnapshot( + valid = listOf(coreSectionForSync()), + unsyncable = emptyList(), + ) + fakeApi.pushResult = Result.success( + PortalSyncPushResponse( + syncTime = "2026-07-11T12:00:00Z", + profilePreferencesAccepted = true, + canonicalProfilePreferenceSections = listOf(coreCanonicalForSync()), + ), + ) + + createManager().sync() + + val preferenceIndex = fakeApi.pushPayloads.indexOfFirst { + it.profilePreferenceSections != null + } + val metadataIndex = fakeApi.pushPayloads.indexOfLast { it.allProfiles != null } + assertTrue(metadataIndex >= 0) + assertTrue(preferenceIndex > metadataIndex) + assertTrue( + "profile-a" in fakeApi.pushPayloads[metadataIndex].allProfiles.orEmpty().map { it.id }, + ) + } + + @Test + fun everyLogicalPushCallConsumesTheSharedRateLimit() = runTest { + authenticate() + repeat(20) { index -> + fakeUserProfileRepo.setActiveProfileForTest("profile-$index") + } + fakeUserProfileRepo.setActiveProfileForTest("profile-a") + val ordinary = buildSessions(1).single() + fakeSyncRepo.workoutSessionsToReturn = listOf(ordinary) + fakeProfilePreferenceSyncRepo.dirtySnapshot = ProfilePreferenceDirtySnapshot( + valid = List(20) { index -> + ProfilePreferenceSectionSyncDto( + key = ProfilePreferenceSectionKey( + "profile-$index", + ProfilePreferenceSectionName.RACK, + ), + documentVersion = 1, + baseRevision = 0, + clientModifiedAtEpochMs = 1_783_771_200_000L, + localGeneration = 1, + payload = buildJsonObject { put("padding", "x".repeat(174_700)) }, + ) + }, + unsyncable = emptyList(), + ) + fakeApi.pushResult = Result.success( + PortalSyncPushResponse( + syncTime = "2026-07-11T12:00:00Z", + profilePreferencesAccepted = true, + ), + ) + + val result = createManager( + rateLimiter = ClientRateLimiter(nowMs = { 0L }), + ).sync() + + assertTrue(result.isSuccess) + assertEquals(SyncConfig.PUSH_RATE_LIMIT_PER_MIN, fakeApi.pushPayloads.size) + assertEquals( + SyncConfig.PUSH_RATE_LIMIT_PER_MIN - 1, + fakeApi.pushPayloads.count { it.profilePreferenceSections != null }, + ) + assertTrue(fakeProfilePreferenceSyncRepo.appliedPushOutcomes.isEmpty()) + assertEquals(listOf(ordinary.id), fakeSyncRepo.updateSessionTimestampCalls) + } + // ==================== Telemetry-Aware Batching (audit: 36_852 point rejection) ==================== /** @@ -557,6 +651,28 @@ class PortalPushLimitsTest { assertEquals(listOf("TELEMETRY-A"), duplicates[6].ids) } + private fun coreSectionForSync() = ProfilePreferenceSectionSyncDto( + key = ProfilePreferenceSectionKey("profile-a", ProfilePreferenceSectionName.CORE), + documentVersion = 1, + baseRevision = 0, + clientModifiedAtEpochMs = 1_783_771_200_000L, + localGeneration = 1, + payload = buildJsonObject { + put("bodyWeightKg", 80.0) + put("weightUnit", "KG") + put("weightIncrement", 0.5) + }, + ) + + private fun coreCanonicalForSync() = PortalProfilePreferenceSectionCanonicalDto( + localProfileId = "profile-a", + section = "CORE", + documentVersion = 1, + serverRevision = 1, + serverUpdatedAt = "2026-07-11T12:00:00Z", + payload = coreSectionForSync().payload, + ) + private fun stubPortalSession(id: String) = PortalWorkoutSessionDto( id = id, userId = "user-123", diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/PortalSyncAdapterProfilePreferencesTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/PortalSyncAdapterProfilePreferencesTest.kt new file mode 100644 index 000000000..b77bc2ddb --- /dev/null +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/PortalSyncAdapterProfilePreferencesTest.kt @@ -0,0 +1,46 @@ +package com.devil.phoenixproject.data.sync + +import com.devil.phoenixproject.domain.model.ProfilePreferenceSectionName +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import kotlinx.serialization.json.put + +class PortalSyncAdapterProfilePreferencesTest { + @Test + fun `push adapter maps audit timestamp but keeps generation off wire`() { + val source = ProfilePreferenceSectionSyncDto( + key = ProfilePreferenceSectionKey("profile-a", ProfilePreferenceSectionName.CORE), + documentVersion = 1, + baseRevision = 9_007_199_254_740_991L, + clientModifiedAtEpochMs = 1_783_771_200_000L, + localGeneration = 9, + payload = buildJsonObject { + put("bodyWeightKg", 82.5) + put("weightUnit", "KG") + put("weightIncrement", 0.5) + }, + ) + + val prepared = PortalSyncAdapter.toPortalProfilePreferenceMutation(source) + + assertEquals(9L, prepared.sentLocalGeneration) + assertEquals(9_007_199_254_740_991L, prepared.wire.baseRevision) + assertEquals("CORE", prepared.wire.section) + assertEquals("2026-07-11T12:00:00Z", prepared.wire.clientModifiedAt) + val encoded = PortalWireJson.encodeToJsonElement( + PortalProfilePreferenceSectionMutationDto.serializer(), + prepared.wire, + ).jsonObject + assertFalse(encoded.getValue("baseRevision").jsonPrimitive.isString) + assertEquals( + 9_007_199_254_740_991L, + encoded.getValue("baseRevision").jsonPrimitive.long, + ) + assertFalse("localGeneration" in encoded) + } +} diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/PortalTokenRefreshTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/PortalTokenRefreshTest.kt index 514d597d8..8a8ba3565 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/PortalTokenRefreshTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/PortalTokenRefreshTest.kt @@ -4,6 +4,7 @@ import com.devil.phoenixproject.domain.model.currentTimeMillis import com.devil.phoenixproject.testutil.FakeExternalActivityRepository import com.devil.phoenixproject.testutil.FakeGamificationRepository import com.devil.phoenixproject.testutil.FakePortalApiClient +import com.devil.phoenixproject.testutil.FakeProfilePreferenceSyncRepository import com.devil.phoenixproject.testutil.FakeRepMetricRepository import com.devil.phoenixproject.testutil.FakeSyncRepository import com.devil.phoenixproject.testutil.FakeUserProfileRepository @@ -48,6 +49,7 @@ class PortalTokenRefreshTest { private val fakeUserProfileRepo = FakeUserProfileRepository() private val fakeExternalActivityRepo = FakeExternalActivityRepository() private val fakeVelocityRepo = FakeVelocityOneRepMaxRepository() + private val fakeProfilePreferenceSyncRepo = FakeProfilePreferenceSyncRepository() private fun createManager() = SyncManager( apiClient = fakeApi, @@ -56,8 +58,10 @@ class PortalTokenRefreshTest { gamificationRepository = fakeGamificationRepo, repMetricRepository = fakeRepMetricRepo, userProfileRepository = fakeUserProfileRepo, + profilePreferenceSyncRepository = fakeProfilePreferenceSyncRepo, externalActivityRepository = fakeExternalActivityRepo, velocityOneRepMaxRepository = fakeVelocityRepo, + isProfilePreferenceMigrationReady = { true }, ) // ==================== isTokenExpired Buffer ==================== diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/ProfilePreferenceSyncDtosTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/ProfilePreferenceSyncDtosTest.kt new file mode 100644 index 000000000..68a832264 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/ProfilePreferenceSyncDtosTest.kt @@ -0,0 +1,180 @@ +package com.devil.phoenixproject.data.sync + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray + +class ProfilePreferenceSyncDtosTest { + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + explicitNulls = false + } + + @Test + fun `legacy responses decode without falsely acknowledging preferences`() { + val push = json.decodeFromString( + """{"syncTime":"2026-07-11T12:00:00Z"}""", + ) + val pull = json.decodeFromString( + """{"syncTime":1783771200000}""", + ) + + assertNull(push.profilePreferencesAccepted) + assertTrue(push.canonicalProfilePreferenceSections.isEmpty()) + assertTrue(push.profilePreferenceRejections.isEmpty()) + assertNull(pull.profilePreferenceSections) + + val encodedPush = json.encodeToJsonElement(push).jsonObject + val encodedPull = json.encodeToJsonElement(pull).jsonObject + assertFalse("profilePreferencesAccepted" in encodedPush) + assertEquals( + JsonArray(emptyList()), + encodedPush.getValue("canonicalProfilePreferenceSections"), + ) + assertEquals( + JsonArray(emptyList()), + encodedPush.getValue("profilePreferenceRejections"), + ) + assertFalse("profilePreferenceSections" in encodedPull) + } + + @Test + fun `mutation has exact value-only wire keys and JSON number revisions`() { + val mutation = PortalProfilePreferenceSectionMutationDto( + localProfileId = "profile-a", + section = "WORKOUT", + documentVersion = 1, + baseRevision = 4, + clientModifiedAt = "2026-07-11T12:00:00Z", + payload = buildJsonObject { + put("version", 1) + put("voiceStopEnabled", true) + }, + ) + + val encoded = json.encodeToJsonElement(mutation).jsonObject + assertEquals( + setOf( + "localProfileId", "section", "documentVersion", "baseRevision", + "clientModifiedAt", "payload", + ), + encoded.keys, + ) + assertEquals("profile-a", encoded.getValue("localProfileId").jsonPrimitive.content) + assertEquals("WORKOUT", encoded.getValue("section").jsonPrimitive.content) + assertFalse(encoded.getValue("documentVersion").jsonPrimitive.isString) + assertEquals(1, encoded.getValue("documentVersion").jsonPrimitive.int) + assertFalse(encoded.getValue("baseRevision").jsonPrimitive.isString) + assertEquals(4L, encoded.getValue("baseRevision").jsonPrimitive.long) + assertEquals( + "2026-07-11T12:00:00Z", + encoded.getValue("clientModifiedAt").jsonPrimitive.content, + ) + val payload = encoded.getValue("payload") + assertTrue(payload is JsonObject) + assertEquals(setOf("version", "voiceStopEnabled"), payload.jsonObject.keys) + assertFalse(payload.jsonObject.getValue("version").jsonPrimitive.isString) + assertTrue(payload.jsonObject.getValue("voiceStopEnabled").jsonPrimitive.boolean) + } + + @Test + fun `recursive normalized local-only names cannot enter mutation or canonical DTOs`() { + listOf( + "safeWord", + "safeWord\u0130", + "SAFE_WORD", + "safe-word-calibrated", + "adults_only_confirmed", + "adultsOnlyPrompted", + "local_generation", + "DIRTY", + "legacy-migration-version", + ).forEach { forbidden -> + val adversarial = buildJsonObject { + put("version", 1) + putJsonArray("nested") { + add(buildJsonObject { put(forbidden, "must-not-enter-wire-dto") }) + } + } + assertFailsWith(forbidden) { + PortalProfilePreferenceSectionMutationDto( + localProfileId = "profile-a", + section = "WORKOUT", + documentVersion = 1, + baseRevision = 4, + clientModifiedAt = "2026-07-11T12:00:00Z", + payload = adversarial, + ) + } + assertFailsWith(forbidden) { + PortalProfilePreferenceSectionCanonicalDto( + localProfileId = "profile-a", + section = "WORKOUT", + documentVersion = 1, + serverRevision = 7, + serverUpdatedAt = "2026-07-11T12:01:00Z", + payload = adversarial, + ) + } + } + } + + @Test + fun `canonical and rejection encode exact keys types and revision identity`() { + val canonical = PortalProfilePreferenceSectionCanonicalDto( + localProfileId = "profile-a", + section = "CORE", + documentVersion = 1, + serverRevision = 7, + serverUpdatedAt = "2026-07-11T12:01:00Z", + payload = buildJsonObject { put("weightUnit", "KG") }, + ) + val rejection = ProfilePreferenceSectionRejectionDto( + localProfileId = "profile-a", + section = "CORE", + serverRevision = 7, + reason = "REVISION_CONFLICT", + canonicalSection = canonical, + ) + + val encodedCanonical = json.encodeToJsonElement(canonical).jsonObject + assertEquals( + setOf( + "localProfileId", "section", "documentVersion", "serverRevision", + "serverUpdatedAt", "payload", + ), + encodedCanonical.keys, + ) + assertFalse(encodedCanonical.getValue("serverRevision").jsonPrimitive.isString) + assertEquals(7L, encodedCanonical.getValue("serverRevision").jsonPrimitive.long) + assertTrue(encodedCanonical.getValue("payload") is JsonObject) + + val encodedRejection = json.encodeToJsonElement(rejection).jsonObject + assertEquals( + setOf( + "localProfileId", "section", "serverRevision", "reason", "canonicalSection", + ), + encodedRejection.keys, + ) + assertFalse(encodedRejection.getValue("serverRevision").jsonPrimitive.isString) + assertEquals(7L, encodedRejection.getValue("serverRevision").jsonPrimitive.long) + assertEquals("REVISION_CONFLICT", encodedRejection.getValue("reason").jsonPrimitive.content) + assertEquals(encodedCanonical, encodedRejection.getValue("canonicalSection")) + } +} diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/ProfilePreferenceSyncPlannerTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/ProfilePreferenceSyncPlannerTest.kt new file mode 100644 index 000000000..28a56e40f --- /dev/null +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/ProfilePreferenceSyncPlannerTest.kt @@ -0,0 +1,436 @@ +package com.devil.phoenixproject.data.sync + +import com.devil.phoenixproject.domain.model.ProfilePreferenceSectionName +import com.devil.phoenixproject.testutil.readProjectFile +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +class ProfilePreferenceSyncPlannerTest { + @Test + fun `planner isolates oversized section and keeps valid siblings`() { + val valid = preparedMutation( + "profile-a", + ProfilePreferenceSectionName.CORE, + payloadChars = 64, + ) + val oversized = preparedMutation( + "profile-a", + ProfilePreferenceSectionName.RACK, + payloadChars = MAX_PROFILE_PREFERENCE_SECTION_BYTES + 1, + ) + + val plan = planProfilePreferencePushChunks(basePayload(), listOf(oversized, valid)) + + assertEquals(1, plan.unsyncable.size) + assertEquals(ProfilePreferenceSectionName.RACK, plan.unsyncable.single().key.section) + assertEquals(1L, plan.unsyncable.single().localGeneration) + assertEquals( + ProfilePreferenceSyncIssueReason.SECTION_TOO_LARGE.name, + plan.unsyncable.single().reason, + ) + assertEquals( + listOf(ProfilePreferenceSectionName.CORE), + plan.chunks.single().ledger.keys.map { it.section }, + ) + } + + @Test + fun `every planned request stays within preference request cap`() { + val mutations = (0 until 12).map { index -> + preparedMutation( + "profile-$index", + ProfilePreferenceSectionName.WORKOUT, + payloadChars = 90_000, + ) + } + + val plan = planProfilePreferencePushChunks(basePayload(), mutations) + + assertTrue(plan.chunks.size > 1) + plan.chunks.forEach { chunk -> + val bytes = encodePortalSyncPayload(chunk.payload).rawBytes.size + assertTrue(bytes <= MAX_PROFILE_PREFERENCE_REQUEST_BYTES) + } + } + + @Test + fun `shared raw byte goldens pin scanner spans and inclusive boundaries`() { + val recipes = byteGoldenRecipes() + assertEquals(1, recipes.version) + + recipes.sectionTargetBytes.forEach { target -> + val sectionRaw = fillAsciiPadding( + recipes.sectionRawTemplate, + recipes.paddingMarker, + target, + ) + val completeRaw = "{\"profilePreferenceSections\":[$sectionRaw]}" + val span = scanProfilePreferenceElementSpans(completeRaw).single() + assertEquals(sectionRaw, completeRaw.substring(span.start, span.endExclusive)) + assertEquals( + target, + completeRaw.substring(span.start, span.endExclusive).encodeToByteArray().size, + ) + assertEquals( + target == 262_145, + completeRaw.substring(span.start, span.endExclusive).encodeToByteArray().size > + MAX_PROFILE_PREFERENCE_SECTION_BYTES, + ) + assertTrue("\"weightKg\":20.0" in sectionRaw) + assertTrue("\"createdAt\":-1e3" in sectionRaw) + assertTrue("π界🙂" in sectionRaw) + assertTrue("\\\"" in sectionRaw) + assertTrue("\\\\" in sectionRaw) + val decodedMutation = PortalWireJson.decodeFromString( + PortalProfilePreferenceSectionMutationDto.serializer(), + sectionRaw, + ) + assertEquals("profile-a", decodedMutation.localProfileId) + assertEquals("RACK", decodedMutation.section) + assertEquals(0L, decodedMutation.baseRevision) + assertTrue(decodedMutation.payload.getValue("items") is JsonArray) + PortalWireJson.parseToJsonElement(completeRaw) + } + + recipes.requestTargetBytes.forEach { target -> + val sectionRaw = recipes.sectionRawTemplate.replace(recipes.paddingMarker, "x") + val requestTemplate = recipes.requestRawTemplate.replace( + recipes.sectionMarker, + sectionRaw, + ) + val completeRaw = fillAsciiPadding( + requestTemplate, + recipes.paddingMarker, + target, + ) + assertEquals(target, completeRaw.encodeToByteArray().size) + assertEquals( + target == 524_289, + completeRaw.encodeToByteArray().size > MAX_PROFILE_PREFERENCE_REQUEST_BYTES, + ) + val span = scanProfilePreferenceElementSpans(completeRaw).single() + assertEquals(sectionRaw, completeRaw.substring(span.start, span.endExclusive)) + val decodedRequest = PortalWireJson.decodeFromString( + PortalSyncPayload.serializer(), + completeRaw, + ) + assertEquals("golden-device", decodedRequest.deviceId) + assertEquals("profile-a", decodedRequest.profileId) + assertEquals(1, decodedRequest.profilePreferenceSections?.size) + assertEquals("RACK", decodedRequest.profilePreferenceSections?.single()?.section) + } + } + + @Test + fun `planner enforces actual reencoded section boundaries`() { + val recipes = byteGoldenRecipes() + recipes.sectionTargetBytes.forEach { target -> + fun preparedAtSourceBytes(sourceBytes: Int): PreparedProfilePreferenceMutation { + val raw = fillAsciiPadding( + recipes.sectionRawTemplate, + recipes.paddingMarker, + sourceBytes, + ) + val wire = PortalWireJson.decodeFromString( + PortalProfilePreferenceSectionMutationDto.serializer(), + raw, + ) + val section = ProfilePreferenceSectionName.valueOf(wire.section) + return PreparedProfilePreferenceMutation( + wire = wire, + key = ProfilePreferenceSectionKey(wire.localProfileId, section), + sentLocalGeneration = 1, + ) + } + + var prepared = preparedAtSourceBytes(target) + var reencoded = encodePortalSyncPayload(minimalPayload(listOf(prepared))) + // The JSON decoder normalizes the golden's -1e3 numeric lexeme to -1000.0. + // Calibrate only its ASCII padding so the real encoder lands on the byte cap. + val initialBytes = reencoded.preferenceElementByteCount( + reencoded.preferenceElementSpans.single(), + ) + prepared = preparedAtSourceBytes(target - (initialBytes - target)) + reencoded = encodePortalSyncPayload(minimalPayload(listOf(prepared))) + val actualBytes = reencoded.preferenceElementByteCount( + reencoded.preferenceElementSpans.single(), + ) + assertEquals(target, actualBytes) + + val plan = planProfilePreferencePushChunks(basePayload(), listOf(prepared)) + if (target <= MAX_PROFILE_PREFERENCE_SECTION_BYTES) { + assertTrue(plan.unsyncable.isEmpty()) + assertEquals(listOf(prepared.key), plan.chunks.single().ledger.keys.toList()) + } else { + assertTrue(plan.chunks.isEmpty()) + assertEquals( + ProfilePreferenceSyncIssueReason.SECTION_TOO_LARGE.name, + plan.unsyncable.single().reason, + ) + } + } + } + + @Test + fun `actual encoder request boundary is inclusive and overflow is split`() { + listOf(MAX_PROFILE_PREFERENCE_REQUEST_BYTES, MAX_PROFILE_PREFERENCE_REQUEST_BYTES + 1) + .forEach { target -> + val mutations = requestSizedMutations(target) + val encoded = encodePortalSyncPayload(minimalPayload(mutations)) + assertEquals(target, encoded.rawBytes.size) + assertTrue( + encoded.preferenceElementSpans.all { span -> + encoded.preferenceElementByteCount(span) <= + MAX_PROFILE_PREFERENCE_SECTION_BYTES + }, + ) + + val plan = planProfilePreferencePushChunks(basePayload(), mutations) + assertTrue(plan.unsyncable.isEmpty()) + if (target == MAX_PROFILE_PREFERENCE_REQUEST_BYTES) { + assertEquals(1, plan.chunks.size) + assertEquals( + target, + encodePortalSyncPayload(plan.chunks.single().payload).rawBytes.size, + ) + } else { + assertTrue(plan.chunks.size > 1) + } + plan.chunks.forEach { chunk -> + assertTrue( + encodePortalSyncPayload(chunk.payload).rawBytes.size <= + MAX_PROFILE_PREFERENCE_REQUEST_BYTES, + ) + assertEquals( + chunk.payload.profilePreferenceSections.orEmpty().size, + chunk.ledger.size, + ) + } + } + } + + @Test + fun `one item request overflow is diagnosed and never emitted`() { + val base = PortalSyncPayload( + deviceId = "x".repeat(MAX_PROFILE_PREFERENCE_REQUEST_BYTES), + lastSync = 0, + ) + val mutation = preparedMutation("profile-a", ProfilePreferenceSectionName.CORE, 32) + + val plan = planProfilePreferencePushChunks(base, listOf(mutation)) + + assertTrue(plan.chunks.isEmpty()) + assertEquals( + ProfilePreferenceSyncIssueReason.REQUEST_TOO_LARGE.name, + plan.unsyncable.single().reason, + ) + } + + @Test + fun `planner excludes every duplicate key and preserves unique sibling`() { + val first = preparedMutation("profile-a", ProfilePreferenceSectionName.CORE, 32) + val second = first.copy(sentLocalGeneration = 2) + val sibling = preparedMutation("profile-b", ProfilePreferenceSectionName.RACK, 32) + + val plan = planProfilePreferencePushChunks(basePayload(), listOf(second, sibling, first)) + + val duplicateIssues = plan.unsyncable.filter { it.key == first.key } + assertEquals(listOf(1L, 2L), duplicateIssues.map { it.localGeneration }) + assertTrue( + duplicateIssues.all { + it.reason == ProfilePreferenceSyncIssueReason.DUPLICATE_SECTION.name + }, + ) + assertEquals(listOf(sibling.key), plan.chunks.single().ledger.keys.toList()) + } + + @Test + fun `planner is permutation deterministic and strips profile metadata`() { + val sentinel = "SECRET_PROFILE_SENTINEL" + val base = basePayload().copy( + profileId = "$sentinel\u0000", + profileName = sentinel, + allProfiles = listOf(LocalProfileDto(sentinel, sentinel, 0)), + ) + val mutations = List(6) { index -> + preparedMutation("profile-$index", ProfilePreferenceSectionName.RACK, 100_000) + } + + val forward = planProfilePreferencePushChunks(base, mutations) + val reversed = planProfilePreferencePushChunks(base, mutations.reversed()) + + assertEquals( + forward.chunks.map { encodePortalSyncPayload(it.payload).raw }, + reversed.chunks.map { encodePortalSyncPayload(it.payload).raw }, + ) + assertEquals( + forward.chunks.map { it.ledger.entries.toList() }, + reversed.chunks.map { it.ledger.entries.toList() }, + ) + forward.chunks.forEach { chunk -> + assertNull(chunk.payload.profileId) + assertNull(chunk.payload.profileName) + assertNull(chunk.payload.allProfiles) + assertFalse(sentinel in encodePortalSyncPayload(chunk.payload).raw) + assertEquals(chunk.payload.profilePreferenceSections.orEmpty().size, chunk.ledger.size) + } + } + + @Test + fun `scanner decodes escaped top level key and ignores nested decoy`() { + val escapedKey = "\\u0070rofilePreferenceSections" + val element = """{"text":"brackets ] } , [ { and quote \" and slash \\"}""" + val raw = """{"nested":{"profilePreferenceSections":[0]},"$escapedKey":[$element]}""" + + val span = scanProfilePreferenceElementSpans(raw).single() + + assertEquals(element, raw.substring(span.start, span.endExclusive)) + } + + @Test + fun `scanner rejects escaped duplicate and malformed structure with fixed messages`() { + val escapedKey = "\\u0070rofilePreferenceSections" + val duplicate = """{"profilePreferenceSections":[],"$escapedKey":[]}""" + assertEquals( + "DUPLICATE_PROFILE_PREFERENCE_SECTIONS", + assertFailsWith { + scanProfilePreferenceElementSpans(duplicate) + }.message, + ) + + val sentinel = "SECRET_SCAN_SENTINEL" + mapOf( + "[]" to "INVALID_JSON_ROOT", + """{"profilePreferenceSections":[}""" to "INVALID_JSON_STRUCTURE", + """{"profilePreferenceSections":[]} $sentinel""" to "TRAILING_JSON_DATA", + ).forEach { (raw, expected) -> + val error = assertFailsWith { + scanProfilePreferenceElementSpans(raw) + } + assertEquals(expected, error.message) + assertFalse(sentinel in error.message.orEmpty()) + } + assertTrue(scanProfilePreferenceElementSpans("""{"other":[]}""").isEmpty()) + assertTrue( + scanProfilePreferenceElementSpans( + """{"profilePreferenceSections":[]}""", + ).isEmpty(), + ) + } + + @Test + fun `scanner rejects non array preference field with fixed message`() { + val raw = """{"profilePreferenceSections":{}}""" + + val error = assertFailsWith { + scanProfilePreferenceElementSpans(raw) + } + + assertEquals("INVALID_PROFILE_PREFERENCE_ARRAY", error.message) + assertFalse(raw in error.message.orEmpty()) + } + + @Serializable + private data class ProfilePreferenceByteGoldenRecipes( + val version: Int, + val paddingMarker: String, + val sectionMarker: String, + val sectionRawTemplate: String, + val requestRawTemplate: String, + val sectionTargetBytes: List, + val requestTargetBytes: List, + ) + + private fun preparedMutation( + profileId: String, + section: ProfilePreferenceSectionName, + payloadChars: Int, + ): PreparedProfilePreferenceMutation { + val key = ProfilePreferenceSectionKey(profileId, section) + return PreparedProfilePreferenceMutation( + wire = PortalProfilePreferenceSectionMutationDto( + localProfileId = profileId, + section = section.name, + documentVersion = 1, + baseRevision = 0, + clientModifiedAt = "2026-07-11T12:00:00Z", + payload = buildJsonObject { put("padding", "x".repeat(payloadChars)) }, + ), + key = key, + sentLocalGeneration = 1, + ) + } + + private fun byteGoldenRecipes() = + PortalWireJson.decodeFromString( + assertNotNull( + readProjectFile("docs/backend-handoff/profile-preference-byte-goldens.json"), + ), + ) + + private fun minimalPayload( + mutations: List, + ) = PortalSyncPayload( + deviceId = "device", + platform = "android", + lastSync = 0, + profilePreferenceSections = mutations.map { it.wire }, + ) + + private fun requestSizedMutations(targetBytes: Int): List { + val seeds = List(3) { index -> + preparedMutation( + "profile-$index", + ProfilePreferenceSectionName.RACK, + payloadChars = 0, + ) + } + val fixedBytes = encodePortalSyncPayload(minimalPayload(seeds)).rawBytes.size + val paddingBytes = targetBytes - fixedBytes + require(paddingBytes >= 0) + return seeds.mapIndexed { index, seed -> + val padding = paddingBytes / seeds.size + + if (index < paddingBytes % seeds.size) 1 else 0 + seed.copy( + wire = seed.wire.copy( + payload = buildJsonObject { put("padding", "x".repeat(padding)) }, + ), + ) + } + } + + private fun basePayload() = PortalSyncPayload( + deviceId = "device", + lastSync = 0, + profileId = "profile-a", + ) + + private fun fillAsciiPadding( + template: String, + marker: String, + targetBytes: Int, + ): String { + require(marker.isNotEmpty()) + val markerStart = template.indexOf(marker) + require(markerStart >= 0) + require(template.indexOf(marker, markerStart + marker.length) == -1) + val withoutMarker = template.removeRange(markerStart, markerStart + marker.length) + val paddingBytes = targetBytes - withoutMarker.encodeToByteArray().size + require(paddingBytes >= 0) + val result = withoutMarker.substring(0, markerStart) + + "x".repeat(paddingBytes) + + withoutMarker.substring(markerStart) + assertEquals(targetBytes, result.encodeToByteArray().size) + return result + } +} diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/SyncManagerProfilePreferencesTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/SyncManagerProfilePreferencesTest.kt new file mode 100644 index 000000000..3522aae02 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/SyncManagerProfilePreferencesTest.kt @@ -0,0 +1,1171 @@ +package com.devil.phoenixproject.data.sync + +import com.devil.phoenixproject.data.migration.RequiredMigrationState +import com.devil.phoenixproject.data.preferences.SettingsProfileLocalSafetyStore +import com.devil.phoenixproject.data.repository.ActiveProfileContext +import com.devil.phoenixproject.data.repository.SqlDelightProfilePreferencesRepository +import com.devil.phoenixproject.data.repository.SqlDelightUserProfileRepository +import com.devil.phoenixproject.domain.model.ProfileLocalSafetyPreferences +import com.devil.phoenixproject.domain.model.ProfilePreferenceSectionName +import com.devil.phoenixproject.domain.model.WorkoutPreferences +import com.devil.phoenixproject.domain.model.WorkoutSession +import com.devil.phoenixproject.testutil.FakeExternalActivityRepository +import com.devil.phoenixproject.testutil.FakeGamificationRepository +import com.devil.phoenixproject.testutil.FakePortalApiClient +import com.devil.phoenixproject.testutil.FakeProfilePreferenceSyncRepository +import com.devil.phoenixproject.testutil.FakeRepMetricRepository +import com.devil.phoenixproject.testutil.FakeSyncRepository +import com.devil.phoenixproject.testutil.FakeUserProfileRepository +import com.devil.phoenixproject.testutil.FakeVelocityOneRepMaxRepository +import com.devil.phoenixproject.testutil.createTestDatabase +import com.russhwolf.settings.MapSettings +import kotlin.coroutines.cancellation.CancellationException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +class SyncManagerProfilePreferencesTest { + private val harness = Harness() + + @Test + fun `ordinary metadata push precedes preference-only chunks`() = runTest { + harness.preferenceSyncRepository.dirtySnapshot = ProfilePreferenceDirtySnapshot( + valid = listOf(coreSection(generation = 1)), + unsyncable = emptyList(), + ) + harness.api.pushResultsQueue = mutableListOf( + successResponse(), + successResponse( + profilePreferencesAccepted = true, + canonicalProfilePreferenceSections = listOf(coreCanonical(revision = 1)), + ), + ) + + assertTrue(harness.manager(migrationReady = { true }).sync().isSuccess) + + assertEquals(2, harness.api.pushPayloads.size) + val metadataPayload = harness.api.pushPayloads[0] + val sentMetadataProfileIds = metadataPayload.allProfiles.orEmpty().mapTo(linkedSetOf()) { it.id } + assertTrue("profile-a" in sentMetadataProfileIds) + assertNull(metadataPayload.profilePreferenceSections) + val preferencePayload = harness.api.pushPayloads[1] + assertNull(preferencePayload.profileId) + assertNull(preferencePayload.profileName) + assertNull(preferencePayload.allProfiles) + assertTrue(preferencePayload.sessions.isEmpty()) + assertTrue(preferencePayload.routines.isEmpty()) + assertTrue(preferencePayload.personalRecords.isEmpty()) + assertEquals(1, preferencePayload.profilePreferenceSections?.size) + assertEquals( + "profile-a", + preferencePayload.profilePreferenceSections?.single()?.localProfileId, + ) + assertTrue( + preferencePayload.profilePreferenceSections.orEmpty() + .mapTo(linkedSetOf()) { it.localProfileId } + .all { it in sentMetadataProfileIds }, + ) + } + + @Test + fun `migration readiness is read dynamically and only Ready enables preferences`() = runTest { + harness.preferenceSyncRepository.dirtySnapshot = ProfilePreferenceDirtySnapshot( + valid = listOf(coreSection(generation = 1)), + unsyncable = emptyList(), + ) + harness.api.pushResultsQueue = mutableListOf( + successResponse(), + successResponse(), + successResponse( + profilePreferencesAccepted = true, + canonicalProfilePreferenceSections = listOf(coreCanonical(revision = 1)), + ), + ) + var migrationState: RequiredMigrationState = RequiredMigrationState.NotStarted + val manager = harness.manager( + migrationReady = { migrationState is RequiredMigrationState.Ready }, + ) + + assertTrue(manager.sync().isSuccess) + + assertEquals(1, harness.api.pushPayloads.size) + assertEquals(0, harness.preferenceSyncRepository.snapshotCallCount) + assertTrue(harness.preferenceSyncRepository.appliedPushOutcomes.isEmpty()) + + migrationState = RequiredMigrationState.Ready + assertTrue(manager.sync().isSuccess) + + assertEquals(3, harness.api.pushPayloads.size) + assertEquals(1, harness.preferenceSyncRepository.snapshotCallCount) + assertEquals( + listOf("profile-a"), + harness.api.pushPayloads.last().profilePreferenceSections?.map { it.localProfileId }, + ) + } + + @Test + fun `snapshot profiles absent from the sent metadata set are deferred`() = runTest { + val newProfileSection = workoutSection(generation = 2).copy( + key = ProfilePreferenceSectionKey( + "profile-created-during-ordinary-push", + ProfilePreferenceSectionName.WORKOUT, + ), + ) + harness.preferenceSyncRepository.dirtySnapshot = ProfilePreferenceDirtySnapshot( + valid = listOf(coreSection(generation = 1), newProfileSection), + unsyncable = emptyList(), + ) + harness.api.pushResultsQueue = mutableListOf( + successResponse(), + successResponse( + profilePreferencesAccepted = true, + canonicalProfilePreferenceSections = listOf(coreCanonical(revision = 1)), + ), + ) + + assertTrue(harness.manager(migrationReady = { true }).sync().isSuccess) + + val sentMetadataProfileIds = harness.api.pushPayloads[0].allProfiles.orEmpty() + .mapTo(linkedSetOf()) { it.id } + val preferenceParentIds = harness.api.pushPayloads[1].profilePreferenceSections.orEmpty() + .mapTo(linkedSetOf()) { it.localProfileId } + assertTrue("profile-a" in sentMetadataProfileIds) + assertEquals(setOf("profile-a"), preferenceParentIds) + assertTrue(preferenceParentIds.all { it in sentMetadataProfileIds }) + assertFalse(newProfileSection.key.localProfileId in sentMetadataProfileIds) + assertTrue( + harness.preferenceSyncRepository.appliedPushOutcomes + .flatten() + .none { it.key == newProfileSection.key }, + ) + } + + @Test + fun `ordinary push failure never snapshots or sends preferences`() = runTest { + harness.preferenceSyncRepository.dirtySnapshot = ProfilePreferenceDirtySnapshot( + valid = listOf(coreSection(generation = 1)), + unsyncable = emptyList(), + ) + harness.api.pushResultsQueue = mutableListOf( + Result.failure(PortalApiException("ordinary sentinel", statusCode = 503)), + ) + + assertTrue(harness.manager(migrationReady = { true }).sync().isFailure) + + assertEquals(1, harness.api.pushPayloads.size) + assertEquals(0, harness.preferenceSyncRepository.snapshotCallCount) + assertTrue(harness.api.pushPayloads.none { it.profilePreferenceSections != null }) + } + + @Test + fun `legacy backend leaves every preference section dirty`() = runTest { + harness.preferenceSyncRepository.dirtySnapshot = ProfilePreferenceDirtySnapshot( + valid = listOf(coreSection(generation = 1), workoutSection(generation = 2)), + unsyncable = emptyList(), + ) + harness.api.pushResultsQueue = mutableListOf(successResponse(), successResponse()) + + assertTrue(harness.manager(migrationReady = { true }).sync().isSuccess) + + assertEquals(2, harness.api.pushPayloads.size) + assertTrue(harness.preferenceSyncRepository.appliedPushOutcomes.isEmpty()) + } + + @Test + fun `strict legacy 400 leaves preferences dirty and preserves ordinary acknowledgement`() = runTest { + val ordinary = ordinarySession() + harness.syncRepository.workoutSessionsToReturn = listOf(ordinary) + harness.preferenceSyncRepository.dirtySnapshot = ProfilePreferenceDirtySnapshot( + valid = listOf(coreSection(generation = 1)), + unsyncable = emptyList(), + ) + harness.api.pushResultsQueue = mutableListOf( + successResponse(), + Result.failure(PortalApiException("legacy body sentinel", statusCode = 400)), + ) + + assertTrue(harness.manager(migrationReady = { true }).sync().isSuccess) + + assertEquals(2, harness.api.pushPayloads.size) + assertTrue(harness.preferenceSyncRepository.appliedPushOutcomes.isEmpty()) + assertEquals(listOf(ordinary.id), harness.syncRepository.updateSessionTimestampCalls) + } + + @Test + fun `accepted canonical carries sent generation into repository outcome`() = runTest { + harness.preferenceSyncRepository.dirtySnapshot = ProfilePreferenceDirtySnapshot( + valid = listOf(coreSection(generation = 8)), + unsyncable = emptyList(), + ) + harness.api.pushResultsQueue = mutableListOf( + successResponse(), + successResponse( + profilePreferencesAccepted = true, + canonicalProfilePreferenceSections = listOf(coreCanonical(revision = 4)), + ), + ) + + harness.manager(migrationReady = { true }).sync() + + val outcome = harness.preferenceSyncRepository.appliedPushOutcomes.single().single() + assertEquals(8L, outcome.sentLocalGeneration) + assertEquals(4L, outcome.serverRevision) + assertNull(outcome.rejectionReason) + } + + @Test + fun `pushed canonical core is republished to the active profile context`() = runTest { + val harness = SqlPreferenceHarness() + harness.initialize() + val ready = assertIs( + harness.profileRepository.activeProfileContext.value, + ) + harness.profileRepository.updateCore( + ready.profile.id, + ready.preferences.core.value.copy(bodyWeightKg = 72f), + ) + harness.api.pushResultsQueue = mutableListOf( + successResponse(), + successResponse( + profilePreferencesAccepted = true, + canonicalProfilePreferenceSections = listOf( + coreCanonical( + revision = 1, + profileId = ready.profile.id, + bodyWeightKg = 80.0, + ), + ), + ), + ) + + assertTrue(harness.manager().sync().isSuccess) + + assertEquals( + 80f, + assertIs( + harness.profileRepository.activeProfileContext.value, + ).preferences.core.value.bodyWeightKg, + ) + } + + @Test + fun `canonical conflict is applied only through generation ledger`() = runTest { + harness.preferenceSyncRepository.dirtySnapshot = ProfilePreferenceDirtySnapshot( + valid = listOf(coreSection(generation = 11)), + unsyncable = emptyList(), + ) + harness.api.pushResultsQueue = mutableListOf( + successResponse(), + successResponse( + profilePreferencesAccepted = true, + profilePreferenceRejections = listOf( + ProfilePreferenceSectionRejectionDto( + localProfileId = "profile-a", + section = "CORE", + serverRevision = 6, + reason = "REVISION_CONFLICT", + canonicalSection = coreCanonical(revision = 6), + ), + ), + ), + ) + + harness.manager(migrationReady = { true }).sync() + + val outcome = harness.preferenceSyncRepository.appliedPushOutcomes.single().single() + assertEquals(11L, outcome.sentLocalGeneration) + assertEquals("REVISION_CONFLICT", outcome.rejectionReason) + assertEquals(6L, outcome.canonical?.serverRevision) + } + + @Test + fun `first preference chunk failure is nonfatal and attempts no later chunk`() = runTest { + val ordinary = ordinarySession() + harness.syncRepository.workoutSessionsToReturn = listOf(ordinary) + harness.preferenceSyncRepository.dirtySnapshot = multiChunkSnapshot() + harness.api.pushResultsQueue = mutableListOf( + successResponse(), + Result.failure(PortalApiException("first chunk sentinel", statusCode = 503)), + ) + + assertTrue(harness.manager(migrationReady = { true }).sync().isSuccess) + + assertEquals(2, harness.api.pushPayloads.size) + assertEquals(1, harness.api.pushPayloads.count { it.profilePreferenceSections != null }) + assertTrue(harness.preferenceSyncRepository.appliedPushOutcomes.isEmpty()) + assertEquals(listOf(ordinary.id), harness.syncRepository.updateSessionTimestampCalls) + } + + @Test + fun `later preference chunk failure keeps earlier outcome and ordinary acknowledgement`() = runTest { + val ordinary = ordinarySession() + harness.syncRepository.workoutSessionsToReturn = listOf(ordinary) + harness.preferenceSyncRepository.dirtySnapshot = multiChunkSnapshot() + harness.api.pushResultsQueue = mutableListOf( + successResponse(), + successResponse( + profilePreferencesAccepted = true, + canonicalProfilePreferenceSections = listOf(coreCanonical(revision = 1)), + ), + Result.failure(PortalApiException("later chunk sentinel", statusCode = 503)), + ) + + assertTrue(harness.manager(migrationReady = { true }).sync().isSuccess) + + assertEquals(3, harness.api.pushPayloads.size) + assertEquals( + listOf(ProfilePreferenceSectionName.CORE), + harness.preferenceSyncRepository.appliedPushOutcomes + .flatten() + .map { it.key.section }, + ) + assertEquals(listOf(ordinary.id), harness.syncRepository.updateSessionTimestampCalls) + } + + @Test + fun `lost preference response applies nothing and unchanged retry converges by conflict`() = runTest { + harness.preferenceSyncRepository.dirtySnapshot = ProfilePreferenceDirtySnapshot( + valid = listOf(coreSection(generation = 7)), + unsyncable = emptyList(), + ) + val conflict = ProfilePreferenceSectionRejectionDto( + localProfileId = "profile-a", + section = "CORE", + serverRevision = 1, + reason = "REVISION_CONFLICT", + canonicalSection = coreCanonical(revision = 1), + ) + harness.api.pushResultsQueue = mutableListOf( + successResponse(), + Result.failure(PortalApiException("lost response sentinel", statusCode = 503)), + successResponse(), + successResponse( + profilePreferencesAccepted = true, + profilePreferenceRejections = listOf(conflict), + ), + ) + val manager = harness.manager(migrationReady = { true }) + + assertTrue(manager.sync().isSuccess) + assertTrue(harness.preferenceSyncRepository.appliedPushOutcomes.isEmpty()) + + assertTrue(manager.sync().isSuccess) + val outcome = harness.preferenceSyncRepository.appliedPushOutcomes.single().single() + assertEquals(7L, outcome.sentLocalGeneration) + assertEquals("REVISION_CONFLICT", outcome.rejectionReason) + assertEquals(1L, outcome.canonical?.serverRevision) + } + + @Test + fun `preference local failures never erase the successful ordinary acknowledgement`() = runTest { + val ordinary = ordinarySession() + harness.syncRepository.workoutSessionsToReturn = listOf(ordinary) + harness.preferenceSyncRepository.snapshotFailure = IllegalStateException("snapshot sentinel") + harness.api.pushResultsQueue = mutableListOf(successResponse()) + + assertTrue(harness.manager(migrationReady = { true }).sync().isSuccess) + assertEquals(listOf(ordinary.id), harness.syncRepository.updateSessionTimestampCalls) + assertEquals(1, harness.api.pushPayloads.size) + } + + @Test + fun `outcome apply failure is isolated after a complete preference response`() = runTest { + val ordinary = ordinarySession() + harness.syncRepository.workoutSessionsToReturn = listOf(ordinary) + harness.preferenceSyncRepository.dirtySnapshot = ProfilePreferenceDirtySnapshot( + valid = listOf(coreSection(generation = 1)), + unsyncable = emptyList(), + ) + harness.preferenceSyncRepository.applyPushFailure = IllegalStateException("apply sentinel") + harness.api.pushResultsQueue = mutableListOf( + successResponse(), + successResponse( + profilePreferencesAccepted = true, + canonicalProfilePreferenceSections = listOf(coreCanonical(revision = 1)), + ), + ) + + assertTrue(harness.manager(migrationReady = { true }).sync().isSuccess) + assertEquals(listOf(ordinary.id), harness.syncRepository.updateSessionTimestampCalls) + assertTrue(harness.preferenceSyncRepository.appliedPushOutcomes.isEmpty()) + } + + @Test + fun `duplicate response cardinality invalidates only that ledger key`() { + val coreKey = coreSection(generation = 11).key + val workoutKey = workoutSection(generation = 12).key + val ledger = mapOf(coreKey to 11L, workoutKey to 12L) + val coreRejection = ProfilePreferenceSectionRejectionDto( + localProfileId = "profile-a", + section = "CORE", + serverRevision = 7, + reason = "REVISION_CONFLICT", + canonicalSection = coreCanonical(revision = 7), + ) + val cases = listOf( + response( + profilePreferencesAccepted = true, + canonicalProfilePreferenceSections = listOf( + coreCanonical(7), + workoutCanonical(4), + ), + profilePreferenceRejections = listOf(coreRejection), + ), + response( + profilePreferencesAccepted = true, + canonicalProfilePreferenceSections = listOf( + coreCanonical(7), + coreCanonical(8), + workoutCanonical(4), + ), + ), + response( + profilePreferencesAccepted = true, + canonicalProfilePreferenceSections = listOf(workoutCanonical(4)), + profilePreferenceRejections = listOf(coreRejection, coreRejection), + ), + ) + + cases.forEach { response -> + val outcomes = buildProfilePreferencePushOutcomes(response, ledger) + assertEquals(listOf(workoutKey), outcomes.map { it.key }) + assertEquals(12L, outcomes.single().sentLocalGeneration) + assertEquals(4L, outcomes.single().serverRevision) + } + } + + @Test + fun `rejection canonical key or revision mismatch invalidates only that ledger key`() { + val coreKey = coreSection(generation = 11).key + val workoutKey = workoutSection(generation = 12).key + val mismatchedRejections = listOf( + ProfilePreferenceSectionRejectionDto( + localProfileId = "profile-a", + section = "CORE", + serverRevision = 8, + reason = "REVISION_CONFLICT", + canonicalSection = coreCanonical(revision = 7), + ), + ProfilePreferenceSectionRejectionDto( + localProfileId = "profile-a", + section = "CORE", + serverRevision = 8, + reason = "REVISION_CONFLICT", + canonicalSection = workoutCanonical(revision = 8), + ), + ) + + mismatchedRejections.forEach { rejection -> + val outcomes = buildProfilePreferencePushOutcomes( + response( + profilePreferencesAccepted = true, + canonicalProfilePreferenceSections = listOf(workoutCanonical(4)), + profilePreferenceRejections = listOf(rejection), + ), + ledger = mapOf(coreKey to 11L, workoutKey to 12L), + ) + + assertEquals(listOf(workoutKey), outcomes.map { it.key }) + assertEquals(12L, outcomes.single().sentLocalGeneration) + assertEquals(4L, outcomes.single().serverRevision) + } + } + + @Test + fun `missing unknown and malformed response entries leave only their ledger keys dirty`() { + val coreKey = coreSection(generation = 11).key + val workoutKey = workoutSection(generation = 12).key + val ledger = mapOf(coreKey to 11L, workoutKey to 12L) + val workout = workoutCanonical(revision = 4) + val cases = listOf( + response( + profilePreferencesAccepted = true, + canonicalProfilePreferenceSections = listOf( + workout, + coreCanonical(9).copy(localProfileId = "remote-only"), + ), + ), + response( + profilePreferencesAccepted = true, + canonicalProfilePreferenceSections = listOf(coreCanonical(7), workout), + profilePreferenceRejections = listOf( + ProfilePreferenceSectionRejectionDto( + localProfileId = "profile-a", + section = "CORE", + serverRevision = 7, + reason = "UNSAFE_REMOTE_REASON_SENTINEL", + canonicalSection = coreCanonical(7), + ), + ), + ), + response( + profilePreferencesAccepted = true, + canonicalProfilePreferenceSections = listOf(workout), + profilePreferenceRejections = listOf( + ProfilePreferenceSectionRejectionDto( + localProfileId = "profile-a", + section = "CORE", + serverRevision = 7, + reason = "VALIDATION_FAILED", + canonicalSection = coreCanonical(7), + ), + ), + ), + response( + profilePreferencesAccepted = true, + canonicalProfilePreferenceSections = listOf(workout), + profilePreferenceRejections = listOf( + ProfilePreferenceSectionRejectionDto( + localProfileId = "profile-a", + section = "CORE", + serverRevision = 7, + reason = "REVISION_CONFLICT", + canonicalSection = null, + ), + ), + ), + ) + + cases.forEach { response -> + val outcomes = buildProfilePreferencePushOutcomes(response, ledger) + assertEquals(listOf(workoutKey), outcomes.map { it.key }) + assertEquals(12L, outcomes.single().sentLocalGeneration) + } + } + + @Test + fun `profile preference diagnostics never expose raw identities sections or messages`() { + val sentinel = "SECRET_PROFILE_DIAGNOSTIC_SENTINEL" + val key = ProfilePreferenceSectionKey( + localProfileId = "$sentinel\u0000", + section = ProfilePreferenceSectionName.CORE, + ) + val lines = listOf( + profilePreferenceIssueLogLine( + ProfilePreferenceSyncIssue( + key = key, + localGeneration = 9, + reason = ProfilePreferenceSyncIssueReason.INVALID_PROFILE_ID.name, + ), + ), + profilePreferenceIssueLogLine( + ProfilePreferenceSyncIssue( + key = key, + localGeneration = 9, + reason = sentinel, + ), + ), + profilePreferenceMetadataDeferredLogLine(key), + profilePreferenceDuplicateResultLogLine(key), + profilePreferenceInvalidCanonicalLogLine( + ProfilePreferenceCanonicalDecodeResult.Invalid( + localProfileId = sentinel, + section = sentinel, + reason = sentinel, + ), + ), + profilePreferenceChunkFailureLogLine( + PortalApiException(sentinel, statusCode = 503), + ), + profilePreferenceLocalFailureLogLine( + ProfilePreferenceLocalFailureStage.RESPONSE_MAPPING, + ), + ) + + assertEquals( + listOf( + "PROFILE_PREFERENCE_NOT_SENT section=CORE reason=INVALID_PROFILE_ID", + "PROFILE_PREFERENCE_NOT_SENT section=CORE reason=INVALID_PROFILE_PREFERENCE_DIAGNOSTIC", + "PROFILE_PREFERENCE_NOT_SENT section=CORE reason=PROFILE_METADATA_NOT_SENT", + "PROFILE_PREFERENCE_DUPLICATE_RESULT section=CORE", + "PROFILE_PREFERENCE_INVALID_CANONICAL reason=INVALID_PROFILE_PREFERENCE_DIAGNOSTIC", + "PROFILE_PREFERENCE_CHUNK_FAILED status=503", + "PROFILE_PREFERENCE_LOCAL_FAILURE stage=RESPONSE_MAPPING", + ), + lines, + ) + lines.forEach { line -> + assertFalse(sentinel in line) + assertFalse('\u0000' in line) + } + } + + @Test + fun `local preference isolation sanitizes every stage and rethrows cancellation`() = runTest { + val sentinel = "SECRET_LOCAL_FAILURE_SENTINEL" + ProfilePreferenceLocalFailureStage.entries.forEach { stage -> + val emitted = mutableListOf() + val result = isolateProfilePreferenceFailure( + stage = stage, + onFailure = emitted::add, + ) { + throw IllegalStateException(sentinel) + } + + assertNull(result) + assertEquals( + listOf("PROFILE_PREFERENCE_LOCAL_FAILURE stage=${stage.name}"), + emitted, + ) + assertTrue(emitted.none { sentinel in it }) + } + + val emitted = mutableListOf() + assertFailsWith { + isolateProfilePreferenceFailure( + stage = ProfilePreferenceLocalFailureStage.SNAPSHOT, + onFailure = emitted::add, + ) { + throw CancellationException(sentinel) + } + } + assertTrue(emitted.isEmpty()) + } + + @Test + fun `pull planner pre-counts duplicate keys before validation and keeps valid siblings`() { + val sentinel = "SECRET_PULL_PROFILE_SENTINEL" + val plan = planProfilePreferencePullSections( + listOf( + coreCanonical(revision = 3), + coreCanonical(revision = 4).copy(documentVersion = 2), + workoutCanonical(revision = 5), + coreCanonical(revision = 6).copy(localProfileId = "$sentinel\u0000"), + ), + ) + + assertEquals( + listOf(ProfilePreferenceSectionName.WORKOUT), + plan.valid.map { it.key.section }, + ) + assertEquals(1, plan.duplicateKeyCount) + assertEquals(2, plan.invalidCanonicalCount) + } + + @Test + fun `pull diagnostics contain only fixed categories and counts`() { + val sentinel = "SECRET_PULL_DIAGNOSTIC_SENTINEL" + val lines = ProfilePreferencePullDiagnosticCategory.entries.map { category -> + profilePreferencePullCountLogLine(category, 7) + } + profilePreferenceInvalidCanonicalLogLine( + ProfilePreferenceCanonicalDecodeResult.Invalid( + localProfileId = sentinel, + section = sentinel, + reason = sentinel, + ), + ) + + assertEquals( + listOf( + "PROFILE_PREFERENCE_PULL category=INVALID_CANONICAL count=7", + "PROFILE_PREFERENCE_PULL category=DUPLICATE_KEY count=7", + "PROFILE_PREFERENCE_PULL category=LATER_PAGE_IGNORED count=7", + "PROFILE_PREFERENCE_PULL category=UNKNOWN_PROFILE count=7", + "PROFILE_PREFERENCE_PULL category=REPOSITORY_INVALID count=7", + "PROFILE_PREFERENCE_INVALID_CANONICAL reason=INVALID_PROFILE_PREFERENCE_DIAGNOSTIC", + ), + lines, + ) + assertTrue(lines.none { sentinel in it }) + } + + @Test + fun `pull applies known preference section before existing entities`() = runTest { + val mergeEvents = mutableListOf() + harness.preferenceSyncRepository.onApplyPulledSections = { mergeEvents += "preferences" } + harness.syncRepository.onMergeAllPullData = { mergeEvents += "entities" } + harness.api.pullResult = Result.success( + PortalSyncPullResponse( + syncTime = 1_783_771_200_000L, + profilePreferenceSections = listOf(coreCanonical(revision = 3)), + routines = listOf(PullRoutineDto(id = "routine-1", name = "Routine")), + ), + ) + + assertTrue(harness.manager(migrationReady = { true }).sync().isSuccess) + + assertEquals(1, harness.preferenceSyncRepository.pullApplyCallCount) + assertEquals( + 3, + harness.preferenceSyncRepository.appliedPulledSections.single().single().serverRevision, + ) + assertEquals(1, harness.syncRepository.atomicMergeCallCount) + assertEquals(listOf("preferences", "entities"), mergeEvents) + } + + @Test + fun `pulled canonical core is republished to the active profile context`() = runTest { + val harness = SqlPreferenceHarness() + harness.initialize() + harness.establishCleanCore(bodyWeightKg = 72.0, revision = 1) + harness.api.pullResult = Result.success( + PortalSyncPullResponse( + syncTime = 1_783_771_200_000L, + profilePreferenceSections = listOf( + coreCanonical( + revision = 2, + profileId = harness.activeProfileId, + bodyWeightKg = 84.0, + ), + ), + ), + ) + + assertTrue(harness.manager().retryPull().isSuccess) + + assertEquals( + 84f, + assertIs( + harness.profileRepository.activeProfileContext.value, + ).preferences.core.value.bodyWeightKg, + ) + } + + @Test + fun `pull with no applied preference section does not republish active context`() = runTest { + val harness = SqlPreferenceHarness() + harness.initialize() + harness.establishCleanCore(bodyWeightKg = 72.0, revision = 5) + harness.profileLocalSafetyStore.write( + harness.activeProfileId, + ProfileLocalSafetyPreferences(safeWord = "not-yet-published"), + ) + harness.api.pullResult = Result.success( + PortalSyncPullResponse( + syncTime = 1_783_771_200_000L, + profilePreferenceSections = listOf( + coreCanonical( + revision = 4, + profileId = harness.activeProfileId, + bodyWeightKg = 84.0, + ), + ), + ), + ) + + assertTrue(harness.manager().retryPull().isSuccess) + + val activeContext = assertIs( + harness.profileRepository.activeProfileContext.value, + ) + assertEquals(72f, activeContext.preferences.core.value.bodyWeightKg) + assertNull(activeContext.localSafety.safeWord) + } + + @Test + fun `pull reports unknown preference without creating a profile or preference row`() = runTest { + harness.api.pullResult = Result.success( + PortalSyncPullResponse( + syncTime = 1_783_771_200_000L, + profilePreferenceSections = listOf( + coreCanonical(revision = 3).copy(localProfileId = "remote-only-profile"), + ), + ), + ) + + assertTrue(harness.manager(migrationReady = { true }).sync().isSuccess) + + assertEquals(1, harness.preferenceSyncRepository.pullApplyCallCount) + assertEquals( + "remote-only-profile", + harness.preferenceSyncRepository.pulledSectionCalls.single().single() + .key.localProfileId, + ) + assertTrue(harness.preferenceSyncRepository.appliedPulledSections.single().isEmpty()) + assertTrue(harness.profileRepository.allProfiles.value.none { it.id == "remote-only-profile" }) + } + + @Test + fun `localProfiles metadata still does not create mobile profiles`() = runTest { + harness.api.pullResult = Result.success( + PortalSyncPullResponse( + syncTime = 1_783_771_200_000L, + localProfiles = listOf(LocalProfileDto("remote-only-profile", "Remote", 2)), + ), + ) + + assertTrue(harness.manager(migrationReady = { true }).sync().isSuccess) + + assertEquals(0, harness.preferenceSyncRepository.pullApplyCallCount) + assertTrue(harness.profileRepository.allProfiles.value.none { it.id == "remote-only-profile" }) + } + + @Test + fun `pull readiness is dynamic while not Ready still merges ordinary pages`() = runTest { + var migrationState: RequiredMigrationState = RequiredMigrationState.NotStarted + val manager = harness.manager( + migrationReady = { migrationState is RequiredMigrationState.Ready }, + ) + harness.api.pullResultsQueue = mutableListOf( + Result.success( + PortalSyncPullResponse( + syncTime = 1_783_771_200_000L, + hasMore = true, + nextCursor = "page-2", + profilePreferenceSections = listOf(coreCanonical(revision = 2)), + ), + ), + Result.success( + PortalSyncPullResponse( + syncTime = 1_783_771_201_000L, + routines = listOf(PullRoutineDto(id = "routine-1", name = "Routine")), + ), + ), + ) + + assertTrue(manager.sync().isSuccess) + assertEquals(2, harness.api.pullCallCount) + assertEquals(0, harness.preferenceSyncRepository.pullApplyCallCount) + assertEquals(2, harness.syncRepository.atomicMergeCallCount) + + migrationState = RequiredMigrationState.Ready + harness.api.pullResultsQueue = mutableListOf( + Result.success( + PortalSyncPullResponse( + syncTime = 1_783_771_202_000L, + profilePreferenceSections = listOf(coreCanonical(revision = 3)), + routines = listOf(PullRoutineDto(id = "routine-2", name = "Routine 2")), + ), + ), + ) + + assertTrue(manager.sync().isSuccess) + assertEquals(1, harness.preferenceSyncRepository.pullApplyCallCount) + assertEquals( + 3, + harness.preferenceSyncRepository.appliedPulledSections.single().single().serverRevision, + ) + assertEquals(3, harness.syncRepository.atomicMergeCallCount) + } + + @Test + fun `preference apply failure is local and ordinary entities still merge`() = runTest { + harness.preferenceSyncRepository.applyPullFailure = + IllegalStateException("SECRET_PULL_APPLY_FAILURE") + harness.api.pullResult = Result.success( + PortalSyncPullResponse( + syncTime = 1_783_771_200_000L, + profilePreferenceSections = listOf(coreCanonical(revision = 3)), + routines = listOf(PullRoutineDto(id = "routine-1", name = "Routine")), + ), + ) + + assertTrue(harness.manager(migrationReady = { true }).sync().isSuccess) + + assertEquals(1, harness.preferenceSyncRepository.pullApplyCallCount) + assertEquals(1, harness.preferenceSyncRepository.pulledSectionCalls.size) + assertTrue(harness.preferenceSyncRepository.appliedPulledSections.isEmpty()) + assertEquals(1, harness.syncRepository.atomicMergeCallCount) + } + + @Test + fun `pull preference cancellation escapes before ordinary merge`() = runTest { + harness.preferenceSyncRepository.applyPullFailure = + CancellationException("SECRET_PULL_CANCELLATION") + harness.api.pullResult = Result.success( + PortalSyncPullResponse( + syncTime = 1_783_771_200_000L, + profilePreferenceSections = listOf(coreCanonical(revision = 3)), + routines = listOf(PullRoutineDto(id = "routine-1", name = "Routine")), + ), + ) + + assertFailsWith { + harness.manager(migrationReady = { true }).sync() + } + assertEquals(1, harness.preferenceSyncRepository.pullApplyCallCount) + assertEquals(0, harness.syncRepository.atomicMergeCallCount) + } + + @Test + fun `ordinary merge failure keeps earlier preference commit and checkpoint for retry`() = + runTest { + val initialLastSync = 5_000L + harness.tokenStorage.setLastSyncTimestamp(initialLastSync) + harness.syncRepository.atomicMergeShouldFail = true + harness.api.pullResult = Result.success( + PortalSyncPullResponse( + syncTime = 1_783_771_200_000L, + profilePreferenceSections = listOf(coreCanonical(revision = 3)), + routines = listOf(PullRoutineDto(id = "routine-1", name = "Routine")), + ), + ) + val manager = harness.manager(migrationReady = { true }) + + assertTrue(manager.retryPull().isFailure) + assertIs(manager.syncState.value) + assertEquals(initialLastSync, harness.tokenStorage.getLastSyncTimestamp()) + assertEquals(1, harness.preferenceSyncRepository.appliedPulledSections.size) + + harness.syncRepository.atomicMergeShouldFail = false + assertTrue(manager.retryPull().isSuccess) + assertEquals(2, harness.preferenceSyncRepository.pullApplyCallCount) + assertEquals( + 1_783_771_200_000L, + harness.tokenStorage.getLastSyncTimestamp(), + ) + assertEquals(1, harness.syncRepository.atomicMergeCallCount) + } + + @Test + fun `fake pull seam captures raw calls separately from known profile applications`() = runTest { + val repository = FakeProfilePreferenceSyncRepository() + val known = CanonicalProfilePreferenceSection( + key = ProfilePreferenceSectionKey("profile-a", ProfilePreferenceSectionName.CORE), + documentVersion = 1, + serverRevision = 1, + serverUpdatedAtEpochMs = 1_783_771_200_000L, + payload = coreSection(generation = 1).payload, + ) + val unknown = known.copy( + key = ProfilePreferenceSectionKey("remote-only", ProfilePreferenceSectionName.CORE), + ) + + val report = repository.applyPulledSections(listOf(known, unknown)) + + assertEquals(1, repository.pullApplyCallCount) + assertEquals(listOf(listOf(known, unknown)), repository.pulledSectionCalls) + assertEquals(listOf(listOf(known)), repository.appliedPulledSections) + assertEquals(1, report.applied) + assertEquals(1, report.ignoredUnknownProfile) + + repository.applyPullFailure = IllegalStateException("pull apply sentinel") + assertFailsWith { + repository.applyPulledSections(listOf(known)) + } + assertEquals(2, repository.pullApplyCallCount) + assertEquals(listOf(known), repository.pulledSectionCalls.last()) + assertEquals(1, repository.appliedPulledSections.size) + } + + private class Harness { + val tokenStorage = PortalTokenStorage(MapSettings()) + val api = FakePortalApiClient() + val syncRepository = FakeSyncRepository() + val gamificationRepository = FakeGamificationRepository() + val repMetricRepository = FakeRepMetricRepository() + val profileRepository = FakeUserProfileRepository() + val preferenceSyncRepository = FakeProfilePreferenceSyncRepository() + val externalActivityRepository = FakeExternalActivityRepository() + val velocityOneRepMaxRepository = FakeVelocityOneRepMaxRepository() + + init { + profileRepository.setActiveProfileForTest("profile-a") + tokenStorage.saveAuth( + PortalAuthResponse( + token = "token", + user = PortalUser("user", "u@example.com", null, false), + ), + ) + } + + fun manager( + migrationReady: () -> Boolean = { true }, + rateLimiter: ClientRateLimiter = ClientRateLimiter(), + ) = SyncManager( + apiClient = api, + tokenStorage = tokenStorage, + syncRepository = syncRepository, + gamificationRepository = gamificationRepository, + repMetricRepository = repMetricRepository, + userProfileRepository = profileRepository, + profilePreferenceSyncRepository = preferenceSyncRepository, + externalActivityRepository = externalActivityRepository, + velocityOneRepMaxRepository = velocityOneRepMaxRepository, + rateLimiter = rateLimiter, + isProfilePreferenceMigrationReady = migrationReady, + ) + } + + private class SqlPreferenceHarness { + private val database = createTestDatabase() + private val profilePreferencesRepository = + SqlDelightProfilePreferencesRepository(database) + private val gamificationRepository = FakeGamificationRepository() + val profileLocalSafetyStore = SettingsProfileLocalSafetyStore(MapSettings()) + val profileRepository = SqlDelightUserProfileRepository( + database = database, + profilePreferencesRepository = profilePreferencesRepository, + profileLocalSafetyStore = profileLocalSafetyStore, + gamificationRepository = gamificationRepository, + ) + private val preferenceSyncRepository = SqlDelightProfilePreferenceSyncRepository( + database = database, + codec = ProfilePreferenceSyncCodec(), + ) + val tokenStorage = PortalTokenStorage(MapSettings()) + val api = FakePortalApiClient() + private val syncRepository = FakeSyncRepository() + private val repMetricRepository = FakeRepMetricRepository() + private val externalActivityRepository = FakeExternalActivityRepository() + private val velocityOneRepMaxRepository = FakeVelocityOneRepMaxRepository() + val activeProfileId: String + get() = requireNotNull(profileRepository.activeProfile.value?.id) + + suspend fun initialize() { + profilePreferencesRepository.seedMissingProfiles() + profileRepository.reconcileActiveProfileContext() + tokenStorage.saveAuth( + PortalAuthResponse( + token = "token", + user = PortalUser("user", "u@example.com", null, false), + ), + ) + } + + suspend fun establishCleanCore(bodyWeightKg: Double, revision: Long) { + val canonical = planProfilePreferencePullSections( + listOf( + coreCanonical( + revision = revision, + profileId = activeProfileId, + bodyWeightKg = bodyWeightKg, + ), + ), + ).valid.single() + val report = preferenceSyncRepository.applyPushOutcomes( + listOf( + ProfilePreferencePushOutcome( + key = canonical.key, + sentLocalGeneration = 0, + serverRevision = revision, + canonical = canonical, + rejectionReason = null, + ), + ), + ) + assertEquals(1, report.applied) + profileRepository.refreshProfiles() + } + + fun manager() = SyncManager( + apiClient = api, + tokenStorage = tokenStorage, + syncRepository = syncRepository, + gamificationRepository = gamificationRepository, + repMetricRepository = repMetricRepository, + userProfileRepository = profileRepository, + profilePreferenceSyncRepository = preferenceSyncRepository, + externalActivityRepository = externalActivityRepository, + velocityOneRepMaxRepository = velocityOneRepMaxRepository, + isProfilePreferenceMigrationReady = { true }, + ) + } + + companion object { + private fun coreSection(generation: Long) = ProfilePreferenceSectionSyncDto( + key = ProfilePreferenceSectionKey("profile-a", ProfilePreferenceSectionName.CORE), + documentVersion = 1, + baseRevision = 0, + clientModifiedAtEpochMs = 1_783_771_200_000L, + localGeneration = generation, + payload = buildJsonObject { + put("bodyWeightKg", 80.0) + put("weightUnit", "KG") + put("weightIncrement", 0.5) + }, + ) + + private fun workoutSection(generation: Long) = ProfilePreferenceSectionSyncDto( + key = ProfilePreferenceSectionKey("profile-a", ProfilePreferenceSectionName.WORKOUT), + documentVersion = 1, + baseRevision = 0, + clientModifiedAtEpochMs = 1_783_771_200_000L, + localGeneration = generation, + payload = ProfilePreferenceSyncCodec().workoutPayload(WorkoutPreferences()), + ) + + private fun coreCanonical( + revision: Long, + profileId: String = "profile-a", + bodyWeightKg: Double = 80.0, + ) = PortalProfilePreferenceSectionCanonicalDto( + localProfileId = profileId, + section = "CORE", + documentVersion = 1, + serverRevision = revision, + serverUpdatedAt = "2026-07-11T12:00:00Z", + payload = buildJsonObject { + put("bodyWeightKg", bodyWeightKg) + put("weightUnit", "KG") + put("weightIncrement", 0.5) + }, + ) + + private fun workoutCanonical(revision: Long) = PortalProfilePreferenceSectionCanonicalDto( + localProfileId = "profile-a", + section = "WORKOUT", + documentVersion = 1, + serverRevision = revision, + serverUpdatedAt = "2026-07-11T12:00:00Z", + payload = workoutSection(generation = 1).payload, + ) + + private fun successResponse( + profilePreferencesAccepted: Boolean? = null, + canonicalProfilePreferenceSections: List = emptyList(), + profilePreferenceRejections: List = emptyList(), + ): Result = Result.success( + response( + profilePreferencesAccepted = profilePreferencesAccepted, + canonicalProfilePreferenceSections = canonicalProfilePreferenceSections, + profilePreferenceRejections = profilePreferenceRejections, + ), + ) + + private fun response( + profilePreferencesAccepted: Boolean? = null, + canonicalProfilePreferenceSections: List = emptyList(), + profilePreferenceRejections: List = emptyList(), + ) = PortalSyncPushResponse( + syncTime = "2026-07-11T12:00:00Z", + profilePreferencesAccepted = profilePreferencesAccepted, + canonicalProfilePreferenceSections = canonicalProfilePreferenceSections, + profilePreferenceRejections = profilePreferenceRejections, + ) + + private fun ordinarySession() = WorkoutSession( + id = "ordinary-session", + timestamp = 1_783_771_200_000L, + mode = "OldSchool", + reps = 10, + weightPerCableKg = 20f, + totalReps = 10, + exerciseId = "ordinary-exercise", + exerciseName = "Ordinary Exercise", + routineSessionId = null, + profileId = "profile-a", + ) + + private fun multiChunkSnapshot(): ProfilePreferenceDirtySnapshot { + fun largeBoundarySection( + section: ProfilePreferenceSectionName, + generation: Long, + ) = ProfilePreferenceSectionSyncDto( + key = ProfilePreferenceSectionKey("profile-a", section), + documentVersion = 1, + baseRevision = 0, + clientModifiedAtEpochMs = 1_783_771_200_000L, + localGeneration = generation, + payload = buildJsonObject { put("padding", "x".repeat(180_000)) }, + ) + + return ProfilePreferenceDirtySnapshot( + valid = listOf( + coreSection(generation = 1), + largeBoundarySection(ProfilePreferenceSectionName.RACK, generation = 2), + largeBoundarySection(ProfilePreferenceSectionName.WORKOUT, generation = 3), + largeBoundarySection(ProfilePreferenceSectionName.LED, generation = 4), + ), + unsyncable = emptyList(), + ) + } + } +} diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/SyncManagerTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/SyncManagerTest.kt index bcf7879a7..9514c12b5 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/SyncManagerTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/SyncManagerTest.kt @@ -15,6 +15,7 @@ import com.devil.phoenixproject.domain.model.WorkoutSession import com.devil.phoenixproject.testutil.FakeExternalActivityRepository import com.devil.phoenixproject.testutil.FakeGamificationRepository import com.devil.phoenixproject.testutil.FakePortalApiClient +import com.devil.phoenixproject.testutil.FakeProfilePreferenceSyncRepository import com.devil.phoenixproject.testutil.FakeRepMetricRepository import com.devil.phoenixproject.testutil.FakeSyncRepository import com.devil.phoenixproject.testutil.FakeUserProfileRepository @@ -48,6 +49,7 @@ class SyncManagerTest { private val fakeUserProfileRepo = FakeUserProfileRepository() private val fakeExternalActivityRepo = FakeExternalActivityRepository() private val fakeVelocityRepo = FakeVelocityOneRepMaxRepository() + private val fakeProfilePreferenceSyncRepo = FakeProfilePreferenceSyncRepository() private val json = Json { encodeDefaults = true } private fun createManager() = SyncManager( @@ -57,8 +59,10 @@ class SyncManagerTest { gamificationRepository = fakeGamificationRepo, repMetricRepository = fakeRepMetricRepo, userProfileRepository = fakeUserProfileRepo, + profilePreferenceSyncRepository = fakeProfilePreferenceSyncRepo, externalActivityRepository = fakeExternalActivityRepo, velocityOneRepMaxRepository = fakeVelocityRepo, + isProfilePreferenceMigrationReady = { true }, ) /** diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/assessment/AssessmentEngineTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/assessment/AssessmentEngineTest.kt index 8c893cb5b..7c7cfd9ce 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/assessment/AssessmentEngineTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/assessment/AssessmentEngineTest.kt @@ -117,20 +117,16 @@ class AssessmentEngineTest { } @Test - fun `estimateOneRepMax clamps result at per-cable hardware maximum`() { - // Two points with very gentle slope → OLS extrapolation far above 110kg. + fun `estimateOneRepMax clamps total result at dual cable hardware maximum`() { + // Two total-load points with a gentle slope extrapolate beyond the 220kg ceiling. // slope ≈ -0.002, intercept ≈ 1.2 → at 0.17 m/s: ~515kg unclamped. val points = listOf( LoadVelocityPoint(100f, 1.0f), LoadVelocityPoint(105f, 0.99f), ) val result = engine.estimateOneRepMax(points) - assertNotNull(result, "Should produce result") - assertTrue( - result.estimatedOneRepMaxKg <= 110f, - "1RM must be clamped to 110kg per-cable max, got ${result.estimatedOneRepMaxKg}", - ) - assertEquals(110f, result.estimatedOneRepMaxKg, 0.1f, "Should be clamped to exactly 110kg") + assertNotNull(result) + assertEquals(220f, result.estimatedOneRepMaxKg, 0.1f) } // ========================================================================= diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCaseTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCaseTest.kt new file mode 100644 index 000000000..18fcfeeaf --- /dev/null +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCaseTest.kt @@ -0,0 +1,281 @@ +package com.devil.phoenixproject.domain.usecase + +import com.devil.phoenixproject.data.repository.MAX_RECENT_EXERCISE_SESSIONS +import com.devil.phoenixproject.data.repository.VelocityOneRepMaxEntity +import com.devil.phoenixproject.domain.model.WorkoutSession +import com.devil.phoenixproject.testutil.FakeAssessmentRepository +import com.devil.phoenixproject.testutil.FakeVelocityOneRepMaxRepository +import com.devil.phoenixproject.testutil.FakeWorkoutRepository +import kotlin.coroutines.cancellation.CancellationException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlinx.coroutines.test.runTest + +class ResolveCurrentOneRepMaxUseCaseTest { + private val velocity = FakeVelocityOneRepMaxRepository() + private val assessments = FakeAssessmentRepository() + private val workouts = FakeWorkoutRepository() + private val resolver = ResolveCurrentOneRepMaxUseCase(velocity, assessments, workouts) + + @Test + fun `velocity wins over assessment and session`() = runTest { + seedAssessment(totalKg = 120f) + seedSession(perCableKg = 50f, reps = 5) + velocity.latestPassing = VelocityOneRepMaxEntity( + id = 1L, + exerciseId = "bench", + estimatedPerCableKg = 70f, + mvtUsedMs = 0.3f, + r2 = 0.95f, + distinctLoads = 3, + passedQualityGate = true, + computedAt = 30L, + profileId = "athlete-a", + ) + + assertEquals( + CurrentOneRepMax(70f, CurrentOneRepMaxSource.VELOCITY, 30L), + resolver("bench", "athlete-a"), + ) + assertEquals(emptyList(), workouts.recentCompletedRequests) + } + + @Test + fun `assessment override total is normalized to per cable`() = runTest { + assessments.saveAssessment( + exerciseId = "bench", + estimatedOneRepMaxKg = 120f, + loadVelocityDataJson = "[]", + sessionId = null, + userOverrideKg = 140f, + profileId = "athlete-a", + ) + + assertEquals(70f, resolver("bench", "athlete-a")?.perCableKg) + assertEquals(CurrentOneRepMaxSource.ASSESSMENT, resolver("bench", "athlete-a")?.source) + } + + @Test + fun `invalid assessment override falls through to its valid estimate`() = runTest { + seedAssessment(totalKg = 120f, overrideKg = Float.NaN) + + assertEquals( + CurrentOneRepMax(60f, CurrentOneRepMaxSource.ASSESSMENT, 1L), + resolver("bench", "athlete-a"), + ) + } + + @Test + fun `invalid velocity falls through to valid assessment`() = runTest { + seedAssessment(totalKg = 120f) + velocity.latestPassing = velocityEstimate( + perCableKg = Float.POSITIVE_INFINITY, + profileId = "athlete-a", + exerciseId = "bench", + ) + + assertEquals(CurrentOneRepMaxSource.ASSESSMENT, resolver("bench", "athlete-a")?.source) + assertEquals(60f, resolver("bench", "athlete-a")?.perCableKg) + } + + @Test + fun `session fallback uses canonical hybrid and never another profile`() = runTest { + seedSession(perCableKg = 100f, reps = 5, profileId = "athlete-b", timestamp = 40L) + seedSession(perCableKg = 100f, reps = 5, profileId = "athlete-a", timestamp = 20L) + + val result = resolver("bench", "athlete-a") + + assertEquals(112.5f, result?.perCableKg) + assertEquals(CurrentOneRepMaxSource.SESSION, result?.source) + assertEquals(20L, result?.measuredAt) + } + + @Test + fun `session fallback skips newest invalid estimate and uses newest valid bounded row`() = runTest { + seedSession(perCableKg = 100f, reps = 5, timestamp = 20L) + seedSession(perCableKg = Float.NaN, reps = 5, timestamp = 30L) + + val result = resolver("bench", "athlete-a") + + assertEquals(112.5f, result?.perCableKg) + assertEquals(20L, result?.measuredAt) + assertEquals( + FakeWorkoutRepository.RecentCompletedRequest( + exerciseId = "bench", + profileId = "athlete-a", + limit = MAX_RECENT_EXERCISE_SESSIONS, + ), + workouts.recentCompletedRequests.single(), + ) + } + + @Test + fun `session helper uses total reps fallback and rejects invalid load or reps`() { + val base = session(perCableKg = 100f, workingReps = 0, totalReps = 5) + + assertEquals(112.5f, base.estimatedOneRepMaxPerCableOrNull()) + assertNull(base.copy(weightPerCableKg = Float.NaN).estimatedOneRepMaxPerCableOrNull()) + assertNull(base.copy(weightPerCableKg = 0f).estimatedOneRepMaxPerCableOrNull()) + assertNull(base.copy(workingReps = 0, totalReps = 0).estimatedOneRepMaxPerCableOrNull()) + assertNull(base.copy(workingReps = -1, totalReps = -1).estimatedOneRepMaxPerCableOrNull()) + } + + @Test + fun `wrong profile and exercise at higher sources cannot block current session`() = runTest { + velocity.latestPassing = velocityEstimate( + perCableKg = 200f, + profileId = "athlete-b", + exerciseId = "squat", + ) + seedAssessment( + totalKg = 400f, + profileId = "athlete-b", + exerciseId = "squat", + ) + seedSession(perCableKg = 100f, reps = 5, profileId = "athlete-a", timestamp = 20L) + + assertEquals(CurrentOneRepMaxSource.SESSION, resolver("bench", "athlete-a")?.source) + } + + @Test + fun `only five newest sessions are inspected and all invalid sources return null`() = runTest { + velocity.latestPassing = velocityEstimate(perCableKg = Float.NaN) + seedAssessment(totalKg = -1f, overrideKg = Float.POSITIVE_INFINITY) + seedSession(perCableKg = 100f, reps = 5, timestamp = 1L) + repeat(MAX_RECENT_EXERCISE_SESSIONS) { index -> + seedSession(perCableKg = Float.NaN, reps = 5, timestamp = 10L + index) + } + + assertNull(resolver("bench", "athlete-a")) + assertEquals(MAX_RECENT_EXERCISE_SESSIONS, workouts.recentCompletedRequests.single().limit) + } + + @Test + fun `blank IDs fail before reading any source`() = runTest { + assertFailsWith { resolver(" ", "athlete-a") } + assertFailsWith { resolver("bench", " ") } + assertEquals(emptyList(), workouts.recentCompletedRequests) + } + + @Test + fun `fake bounded read validation mirrors production`() = runTest { + assertFailsWith { + workouts.getRecentCompletedSessionsForExercise(" ", "athlete-a", 1) + } + assertFailsWith { + workouts.getRecentCompletedSessionsForExercise("bench", " ", 1) + } + listOf(0, MAX_RECENT_EXERCISE_SESSIONS + 1).forEach { limit -> + assertFailsWith { + workouts.getRecentCompletedSessionsForExercise("bench", "athlete-a", limit) + } + } + assertFailsWith { + workouts.getMostRecentCompletedExerciseId(" ") + } + assertEquals(emptyList(), workouts.recentCompletedRequests) + } + + @Test + fun `ordinary higher source failure propagates instead of selecting a lower source`() = runTest { + seedAssessment(totalKg = 120f) + velocity.latestPassingFailure = IllegalStateException("velocity unavailable") + + val thrown = assertFailsWith { + resolver("bench", "athlete-a") + } + assertEquals("velocity unavailable", thrown.message) + } + + @Test + fun `cancellation from any source propagates`() = runTest { + assessments.latestAssessmentFailure = CancellationException("profile changed") + + val thrown = assertFailsWith { + resolver("bench", "athlete-a") + } + assertEquals("profile changed", thrown.message) + } + + @Test + fun `session read failure propagates when higher sources are absent`() = runTest { + workouts.recentCompletedFailure = IllegalStateException("history unavailable") + + val thrown = assertFailsWith { + resolver("bench", "athlete-a") + } + assertEquals("history unavailable", thrown.message) + } + + private suspend fun seedAssessment( + totalKg: Float, + overrideKg: Float? = null, + exerciseId: String = "bench", + profileId: String = "athlete-a", + ) { + assessments.saveAssessment( + exerciseId, + totalKg, + "[]", + null, + overrideKg, + profileId, + ) + } + + private fun seedSession( + perCableKg: Float, + reps: Int, + profileId: String = "athlete-a", + timestamp: Long = 10L, + ) { + workouts.addSession( + session( + perCableKg = perCableKg, + workingReps = reps, + totalReps = reps, + profileId = profileId, + timestamp = timestamp, + ), + ) + } + + private fun session( + perCableKg: Float, + workingReps: Int, + totalReps: Int, + profileId: String = "athlete-a", + exerciseId: String = "bench", + timestamp: Long = 10L, + ) = WorkoutSession( + id = "$profileId-$exerciseId-$timestamp-$perCableKg", + timestamp = timestamp, + mode = "OldSchool", + reps = totalReps, + weightPerCableKg = perCableKg, + duration = 1_000L, + totalReps = totalReps, + workingReps = workingReps, + exerciseId = exerciseId, + exerciseName = exerciseId, + profileId = profileId, + ) + + private fun velocityEstimate( + perCableKg: Float, + profileId: String = "athlete-a", + exerciseId: String = "bench", + ) = VelocityOneRepMaxEntity( + id = 1L, + exerciseId = exerciseId, + estimatedPerCableKg = perCableKg, + mvtUsedMs = 0.3f, + r2 = 0.95f, + distinctLoads = 3, + passedQualityGate = true, + computedAt = 30L, + profileId = profileId, + ) +} diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/voice/SafeWordDetectionManagerTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/voice/SafeWordDetectionManagerTest.kt new file mode 100644 index 000000000..09b26556d --- /dev/null +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/voice/SafeWordDetectionManagerTest.kt @@ -0,0 +1,65 @@ +package com.devil.phoenixproject.domain.voice + +import com.devil.phoenixproject.domain.model.ProfileLocalSafetyPreferences +import com.devil.phoenixproject.testutil.FakeUserProfileRepository +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlinx.coroutines.test.runTest + +class SafeWordDetectionManagerTest { + @Test + fun `voice stop is ineffective without calibrated active profile phrase`() = runTest { + val profiles = FakeUserProfileRepository().apply { + setActiveProfileForTest(id = "profile-a") + val ready = activeProfileContext.value as com.devil.phoenixproject.data.repository.ActiveProfileContext.Ready + updateWorkout( + ready.profile.id, + ready.preferences.workout.value.copy(voiceStopEnabled = true), + ) + updateLocalSafety( + ready.profile.id, + ProfileLocalSafetyPreferences(safeWord = "phoenix", safeWordCalibrated = false), + ) + } + var factoryCalls = 0 + val factory = object : SafeWordListenerFactory { + override fun create(safeWord: String): SafeWordListener { + factoryCalls++ + error("Factory must not be invoked for ineffective voice stop") + } + } + + SafeWordDetectionManager(profiles, factory).startForWorkout() + + assertEquals(0, factoryCalls) + } + + @Test + fun `fully effective active profile invokes listener factory`() = runTest { + val profiles = FakeUserProfileRepository().apply { + setActiveProfileForTest(id = "profile-a") + val ready = activeProfileContext.value as com.devil.phoenixproject.data.repository.ActiveProfileContext.Ready + updateWorkout( + ready.profile.id, + ready.preferences.workout.value.copy(voiceStopEnabled = true), + ) + updateLocalSafety( + ready.profile.id, + ProfileLocalSafetyPreferences(safeWord = "phoenix", safeWordCalibrated = true), + ) + } + var factoryCalls = 0 + val factory = object : SafeWordListenerFactory { + override fun create(safeWord: String): SafeWordListener { + factoryCalls++ + error("Factory invoked") + } + } + + assertFailsWith { + SafeWordDetectionManager(profiles, factory).startForWorkout() + } + assertEquals(1, factoryCalls) + } +} diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/JustLiftScreenWeightSliderWiringTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/JustLiftScreenWeightSliderWiringTest.kt index 5fda877cd..a2fd36d8f 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/JustLiftScreenWeightSliderWiringTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/JustLiftScreenWeightSliderWiringTest.kt @@ -124,6 +124,28 @@ class JustLiftScreenWeightSliderWiringTest { ) } + @Test + fun justLiftDefaultsReloadWhenReadyProfileChanges() { + val src = readJustLiftScreenSource() + + assertTrue( + src.contains("profileRepository.activeProfileContext.collectAsState()"), + "JustLiftScreen must observe the atomic active-profile context before loading profile defaults.", + ) + assertTrue( + src.contains("LaunchedEffect(readyProfileId)"), + "Just Lift defaults must reload whenever the Ready profile ID changes.", + ) + assertTrue( + src.contains("defaultsProfileId == readyProfileId"), + "Parameter writes must stay disabled until defaults belong to the current Ready profile.", + ) + assertTrue( + !src.contains("LaunchedEffect(Unit) {\n if (!defaultsLoaded)"), + "A one-shot defaults load leaks profile A's values after an in-place switch to profile B.", + ) + } + private fun readJustLiftScreenSource(): String { // Read from disk via the KMP-compatible `readProjectFile` expect/actual helper // (under testutil/). The test runs in commonTest of the same Gradle module that diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/AdultModePresentationTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/components/AdultModePresentationTest.kt similarity index 57% rename from shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/AdultModePresentationTest.kt rename to shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/components/AdultModePresentationTest.kt index c58bfa1a0..127ac871e 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/AdultModePresentationTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/components/AdultModePresentationTest.kt @@ -1,4 +1,4 @@ -package com.devil.phoenixproject.presentation.screen +package com.devil.phoenixproject.presentation.components import kotlin.test.Test import kotlin.test.assertEquals @@ -29,28 +29,4 @@ class AdultModePresentationTest { assertFalse(presentation.usesBespokePinkAccent) } - @Test - fun `dominatrix hint only shows while vulgar mode is enabled and unlock is still hidden`() { - assertTrue( - AdultModePresentation.shouldShowDominatrixHint( - verbalEncouragementEnabled = true, - vulgarModeEnabled = true, - dominatrixModeUnlocked = false, - ), - ) - assertFalse( - AdultModePresentation.shouldShowDominatrixHint( - verbalEncouragementEnabled = true, - vulgarModeEnabled = false, - dominatrixModeUnlocked = false, - ), - ) - assertFalse( - AdultModePresentation.shouldShowDominatrixHint( - verbalEncouragementEnabled = true, - vulgarModeEnabled = true, - dominatrixModeUnlocked = true, - ), - ) - } } diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/components/ProfileIdentityPolicyTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/components/ProfileIdentityPolicyTest.kt new file mode 100644 index 000000000..4f818a879 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/components/ProfileIdentityPolicyTest.kt @@ -0,0 +1,176 @@ +package com.devil.phoenixproject.presentation.components + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import com.devil.phoenixproject.data.repository.UserProfile +import com.devil.phoenixproject.testutil.readProjectFile +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class ProfileIdentityPolicyTest { + @Test + fun `palette selection wraps and invalid stored indexes normalize`() { + assertEquals(PROFILE_COLOR_COUNT, ProfileColors.size) + assertEquals(0, suggestedProfileColorIndex(-1)) + assertEquals(0, suggestedProfileColorIndex(0)) + assertEquals(7, suggestedProfileColorIndex(7)) + assertEquals(0, suggestedProfileColorIndex(8)) + + assertEquals(0, normalizedProfileColorIndex(-1)) + assertEquals(0, normalizedProfileColorIndex(0)) + assertEquals(7, normalizedProfileColorIndex(7)) + assertEquals(0, normalizedProfileColorIndex(8)) + } + + @Test + fun `avatar initials meet text contrast across the shared palette`() { + ProfileColors.forEach { background -> + val foreground = profileInitialsColor(background) + assertTrue( + contrastRatio(foreground, background) >= 4.5f, + "Insufficient initials contrast for $background", + ) + } + } + + @Test + fun `only the active non-default profile may be deleted`() { + assertFalse(canDeleteProfile(profile("default", isActive = true))) + assertFalse(canDeleteProfile(profile("athlete-a", isActive = false))) + assertTrue(canDeleteProfile(profile("athlete-a", isActive = true))) + } + + @Test + fun `switcher may dismiss only while no switch is in flight`() { + assertTrue(canDismissProfileSwitcher(switchingInFlight = false)) + assertFalse(canDismissProfileSwitcher(switchingInFlight = true)) + } + + @Test + fun `identity dialogs are callback only guarded and responsive`() { + val dialogs = assertNotNull( + readProjectFile( + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileDialogs.kt", + ), + ) + + assertFalse(dialogs.contains("UserProfileRepository")) + assertFalse(dialogs.contains("CoroutineScope")) + assertFalse(dialogs.contains("DestructiveConfirmDialog(")) + assertFalse(dialogs.contains("testTag(TestTags.ACTION_")) + assertContains(dialogs, "fun ProfileAddDialog(") + assertContains(dialogs, "fun ProfileEditDialog(") + assertContains(dialogs, "fun ProfileDeleteDialog(") + assertContains(dialogs, "fun ProfileRecoveryDialog(") + assertContains(dialogs, "require(canDeleteProfile(profile))") + assertContains(dialogs, "AlertDialog(") + assertContains(dialogs, "name.trim()") + assertContains(dialogs, "normalizedProfileColorIndex(selectedColorIndex)") + assertContains(dialogs, "ProfileColors.indices.chunked(4)") + assertContains(dialogs, ".size(48.dp)") + assertContains(dialogs, ".selectable(") + assertContains(dialogs, "Role.RadioButton") + assertContains(dialogs, ".selectableGroup()") + assertContains(dialogs, "enabled = !isSubmitting") + assertContains(dialogs, "if (!isSubmitting)") + assertContains(dialogs, "errorMessage: String?") + assertContains(dialogs, "LiveRegionMode.Polite") + assertContains(dialogs, "onDismissRequest = {}") + + val deleteCopyOffset = dialogs.indexOf("Res.string.profile_delete_reassign_message") + assertTrue(deleteCopyOffset >= 0) + val deleteCopyCall = dialogs.substring( + deleteCopyOffset, + minOf(dialogs.length, deleteCopyOffset + 240), + ) + assertContains(deleteCopyCall, "profile.name") + } + + @Test + fun `switcher is switch create only and cannot hide while switching`() { + val switcher = assertNotNull( + readProjectFile( + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileSwitcherSheet.kt", + ), + ) + + assertContains(switcher, "Res.string.profiles_title") + assertContains(switcher, "TestTags.PROFILE_SWITCHER_SHEET") + assertContains(switcher, "TestTags.ACTION_ADD_PROFILE") + assertFalse(switcher.contains("onEditProfile")) + assertFalse(switcher.contains("onDeleteProfile")) + assertFalse(switcher.contains("combinedClickable")) + assertContains(switcher, "rememberUpdatedState") + assertContains(switcher, "switchingInFlight: Boolean") + assertContains(switcher, "errorMessage: String?") + assertContains(switcher, "confirmValueChange") + assertContains(switcher, "targetValue != SheetValue.Hidden") + assertContains(switcher, "sheetGesturesEnabled = !switchingInFlight") + assertContains(switcher, "if (canDismissProfileSwitcher(") + assertContains(switcher, "LiveRegionMode.Polite") + assertContains(switcher, ".selectableGroup()") + val main = assertNotNull( + readProjectFile( + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/EnhancedMainScreen.kt", + ), + ) + assertContains(main, "isSubmitting = switchingInFlight") + } + + @Test + fun `identity surfaces declare accessible semantics and all downstream tags`() { + val identity = assertNotNull( + readProjectFile( + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileIdentityComponents.kt", + ), + ) + val row = assertNotNull( + readProjectFile( + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileListItem.kt", + ), + ) + val tags = assertNotNull( + readProjectFile( + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/util/TestTags.kt", + ), + ) + + assertContains(identity, "minimumInteractiveComponentSize") + assertContains(identity, "Role.Button") + assertContains(identity, "contentDescription = accessibleName") + assertContains(identity, "clearAndSetSemantics") + assertContains(row, "heightIn(min = 56.dp)") + assertContains(row, "Modifier.selectable(") + assertFalse(row.contains("combinedClickable")) + assertFalse(row.contains("onLongClick")) + assertContains(row, "Role.RadioButton") + assertContains(row, "selected = isActive") + assertContains(row, "enabled && !switching") + assertContains(row, "maxLines = 1") + assertContains(row, "TextOverflow.Ellipsis") + listOf( + "PROFILE_SWITCHER_SHEET", + "ACTION_ADD_PROFILE", + "ACTION_EDIT_PROFILE", + "ACTION_DELETE_PROFILE", + ).forEach { tag -> assertContains(tags, "const val $tag") } + } + + private fun profile(id: String, isActive: Boolean) = UserProfile( + id = id, + name = id, + colorIndex = 0, + createdAt = 1L, + isActive = isActive, + ) + + private fun contrastRatio(first: Color, second: Color): Float { + val lighter = maxOf(first.luminance(), second.luminance()) + val darker = minOf(first.luminance(), second.luminance()) + return (lighter + 0.05f) / (darker + 0.05f) + } +} diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/components/ProfilePreferenceInputPolicyTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/components/ProfilePreferenceInputPolicyTest.kt new file mode 100644 index 000000000..4ff79fd8a --- /dev/null +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/components/ProfilePreferenceInputPolicyTest.kt @@ -0,0 +1,151 @@ +package com.devil.phoenixproject.presentation.components + +import com.devil.phoenixproject.domain.model.CoreProfilePreferences +import com.devil.phoenixproject.domain.model.WeightUnit +import com.devil.phoenixproject.domain.model.VbtPreferences +import com.devil.phoenixproject.util.UnitConverter +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ProfilePreferenceInputPolicyTest { + @Test + fun `changing weight unit writes the explicit kilogram default`() { + val current = CoreProfilePreferences( + bodyWeightKg = 80f, + weightUnit = WeightUnit.LB, + weightIncrement = 5f, + ) + + val updated = coreAfterWeightUnitSelection(current, WeightUnit.KG) + + assertEquals(WeightUnit.KG, updated.weightUnit) + assertEquals(0.5f, updated.weightIncrement) + assertEquals(80f, updated.bodyWeightKg) + } + + @Test + fun `changing weight unit writes the explicit pound default`() { + val current = CoreProfilePreferences( + weightUnit = WeightUnit.KG, + weightIncrement = 2.5f, + ) + + val updated = coreAfterWeightUnitSelection(current, WeightUnit.LB) + + assertEquals(WeightUnit.LB, updated.weightUnit) + assertEquals(1f, updated.weightIncrement) + } + + @Test + fun `weight increment choices are unit specific and never automatic`() { + assertEquals( + listOf(0.5f, 1f, 2.5f, 5f), + weightIncrementOptionsFor(WeightUnit.KG), + ) + assertEquals( + listOf(0.1f, 0.5f, 1f, 2.5f, 5f), + weightIncrementOptionsFor(WeightUnit.LB), + ) + } + + @Test + fun `legacy automatic increment displays the unit default without changing storage`() { + val kilograms = CoreProfilePreferences( + weightUnit = WeightUnit.KG, + weightIncrement = -1f, + ) + val pounds = CoreProfilePreferences( + weightUnit = WeightUnit.LB, + weightIncrement = -1f, + ) + + assertEquals(0.5f, displayedWeightIncrement(kilograms)) + assertEquals(1f, displayedWeightIncrement(pounds)) + assertEquals(-1f, kilograms.weightIncrement) + assertEquals(-1f, pounds.weightIncrement) + } + + @Test + fun `adult mode prerequisite changes deactivate dominatrix without relocking it`() { + val unlockedAndActive = VbtPreferences( + enabled = true, + verbalEncouragementEnabled = true, + vulgarModeEnabled = true, + dominatrixModeUnlocked = true, + dominatrixModeActive = true, + ) + + listOf( + vbtAfterEnabledSelection(unlockedAndActive, false), + vbtAfterVerbalEncouragementSelection(unlockedAndActive, false), + vbtAfterVulgarModeSelection(unlockedAndActive, false), + ).forEach { updated -> + assertEquals(true, updated.dominatrixModeUnlocked) + assertEquals(false, updated.dominatrixModeActive) + } + } + + @Test + fun `reselecting the current weight unit preserves its increment`() { + val current = CoreProfilePreferences( + weightUnit = WeightUnit.LB, + weightIncrement = 5f, + ) + + val updated = coreAfterWeightUnitSelection(current, WeightUnit.LB) + + assertEquals(5f, updated.weightIncrement) + } + + @Test + fun `untouched pounds draft preserves the exact authoritative kilograms`() { + assertEquals( + 80f, + bodyWeightKgForSave( + authoritativeBodyWeightKg = 80f, + draft = "176.4", + weightUnit = WeightUnit.LB, + draftEdited = false, + ), + ) + } + + @Test + fun `untouched maximum body weight remains valid after pounds display rounding`() { + assertEquals( + 300f, + bodyWeightKgForSave( + authoritativeBodyWeightKg = 300f, + draft = "661.4", + weightUnit = WeightUnit.LB, + draftEdited = false, + ), + ) + } + + @Test + fun `edited pounds draft is parsed back to kilograms`() { + assertEquals( + UnitConverter.lbToKg(220.5f), + bodyWeightKgForSave( + authoritativeBodyWeightKg = 80f, + draft = "220.5", + weightUnit = WeightUnit.LB, + draftEdited = true, + ), + ) + } + + @Test + fun `edited pounds draft above the canonical maximum is rejected`() { + assertNull( + bodyWeightKgForSave( + authoritativeBodyWeightKg = 300f, + draft = "661.4", + weightUnit = WeightUnit.LB, + draftEdited = true, + ), + ) + } +} diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/components/ProfilePreferencePolicyTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/components/ProfilePreferencePolicyTest.kt new file mode 100644 index 000000000..2a22577fc --- /dev/null +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/components/ProfilePreferencePolicyTest.kt @@ -0,0 +1,77 @@ +package com.devil.phoenixproject.presentation.components + +import com.devil.phoenixproject.data.integration.HealthBodyWeightSyncManager +import com.devil.phoenixproject.domain.model.ExternalBodyMeasurement +import com.devil.phoenixproject.domain.model.IntegrationProvider +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ProfilePreferencePolicyTest { + @Test + fun `newest matching imported body weight is attributed`() { + val measurements = listOf( + measurement("apple-old", IntegrationProvider.APPLE_HEALTH, 80.01, 100L), + measurement("google-new", IntegrationProvider.GOOGLE_HEALTH, 79.98, 300L), + measurement("apple-middle", IntegrationProvider.APPLE_HEALTH, 80.0, 200L), + ) + + assertEquals( + 300L, + latestImportedBodyWeightMeasuredAt( + profileId = "a", + bodyWeightKg = 80f, + measurements = measurements, + ), + ) + } + + @Test + fun `unset and wrong profile provider unit type or tolerance are rejected`() { + val rejected = listOf( + measurement("profile", IntegrationProvider.APPLE_HEALTH, 80.0, 1L, profileId = "b"), + measurement("provider", IntegrationProvider.HEVY, 80.0, 2L), + measurement("unit", IntegrationProvider.APPLE_HEALTH, 80.0, 3L, unit = "lb"), + measurement("type", IntegrationProvider.APPLE_HEALTH, 80.0, 4L, type = "height"), + measurement("tolerance", IntegrationProvider.APPLE_HEALTH, 80.051, 5L), + ) + + assertNull(latestImportedBodyWeightMeasuredAt("a", 0f, rejected)) + assertNull(latestImportedBodyWeightMeasuredAt("a", Float.NaN, rejected)) + assertNull(latestImportedBodyWeightMeasuredAt("a", 80f, rejected)) + } + + @Test + fun `LED indices zero through seven remain stable`() { + assertEquals((0..7).toList(), (0..7).map { normalizedLedSchemeIndex(it, 8) }) + } + + @Test + fun `negative and future LED values display zero without storage writes`() { + var storedIndex = -1 + assertEquals(0, normalizedLedSchemeIndex(storedIndex, 8)) + assertEquals(-1, storedIndex) + + storedIndex = 8 + assertEquals(0, normalizedLedSchemeIndex(storedIndex, 8)) + assertEquals(8, storedIndex) + } + + private fun measurement( + id: String, + provider: IntegrationProvider, + value: Double, + measuredAt: Long, + profileId: String = "a", + unit: String = HealthBodyWeightSyncManager.UNIT_KG, + type: String = HealthBodyWeightSyncManager.MEASUREMENT_TYPE_WEIGHT, + ) = ExternalBodyMeasurement( + externalId = id, + provider = provider, + measurementType = type, + value = value, + unit = unit, + measuredAt = measuredAt, + profileId = profileId, + ) +} diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/components/WeightIncrementWiringTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/components/WeightIncrementWiringTest.kt index c136979ed..695639987 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/components/WeightIncrementWiringTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/components/WeightIncrementWiringTest.kt @@ -2,6 +2,7 @@ package com.devil.phoenixproject.presentation.components import com.devil.phoenixproject.domain.model.UserPreferences import com.devil.phoenixproject.domain.model.WeightUnit +import com.devil.phoenixproject.testutil.readProjectFile import com.devil.phoenixproject.util.Constants import com.devil.phoenixproject.util.UnitConverter import kotlin.math.abs @@ -155,4 +156,33 @@ class WeightIncrementWiringTest { UserPreferences(weightUnit = WeightUnit.LB, weightIncrement = -1f).effectiveWeightIncrement, ) } + + @Test + fun profileIncrementFeedsRoutineAndSingleExercisePerSetControls() { + val routineEditor = requireNotNull( + readProjectFile( + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/RoutineEditorScreen.kt", + ), + ) + val singleExercise = requireNotNull( + readProjectFile( + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/SingleExerciseScreen.kt", + ), + ) + val exerciseEditor = requireNotNull( + readProjectFile( + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseEditBottomSheet.kt", + ), + ) + + listOf(routineEditor, singleExercise).forEach { source -> + assertTrue( + Regex( + """weightStepOverride\s*=\s*userPreferences\.effectiveWeightIncrementKg""", + ).containsMatchIn(source), + ) + } + assertTrue(exerciseEditor.contains("if (weightStepOverride > 0f)")) + assertTrue(exerciseEditor.contains("kgToDisplay(weightStepOverride, weightUnit)")) + } } diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngineIntegrationTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngineIntegrationTest.kt index 83ab13e4d..27951fe4c 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngineIntegrationTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngineIntegrationTest.kt @@ -1,5 +1,7 @@ package com.devil.phoenixproject.presentation.manager +import com.devil.phoenixproject.data.preferences.SingleExerciseDefaults +import com.devil.phoenixproject.data.repository.ActiveProfileContext import com.devil.phoenixproject.domain.model.CycleDay import com.devil.phoenixproject.domain.model.Exercise import com.devil.phoenixproject.domain.model.ProgramMode @@ -17,6 +19,7 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlin.test.assertIs import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest @@ -33,13 +36,97 @@ import kotlinx.coroutines.test.runTest */ class ActiveSessionEngineIntegrationTest { + @Test + fun workoutStartIsRejectedWhileProfileContextIsSwitching() = runTest { + val harness = DWSMTestHarness(this) + try { + harness.fakeUserProfileRepo.recoverPendingProfileTransitionForStartup() + + harness.activeSessionEngine.startWorkout() + advanceUntilIdle() + + assertIs( + harness.activeSessionEngine.coordinator.workoutState.value, + ) + assertEquals(0, harness.fakeBleRepo.workoutParameters.size) + } finally { + harness.cleanup() + } + } + + @Test + fun quickStartDefaultsFollowActiveProfileAndRoundTripWithoutLoss() = runTest { + val harness = DWSMTestHarness(this) + try { + val justLiftA = JustLiftDefaults( + weightPerCableKg = 31.5f, + weightChangePerRep = 3, + workoutModeId = 10, + eccentricLoadPercentage = 120, + echoLevelValue = 3, + stallDetectionEnabled = false, + repCountTimingName = "BOTTOM", + restSeconds = 95, + ) + val singleA = SingleExerciseDefaults( + exerciseId = "bench", + setReps = listOf(8, null, 6), + weightPerCableKg = 42.5f, + setWeightsPerCableKg = listOf(40f, 42.5f, 45f), + progressionKg = 2.5f, + setRestSeconds = listOf(60, 75, 90), + workoutModeId = 4, + eccentricLoadPercentage = 130, + echoLevelValue = 2, + duration = 45, + isAMRAP = true, + perSetRestTime = true, + defaultRackItemIds = listOf("vest", "belt"), + ) + + harness.activeSessionEngine.saveJustLiftDefaults(justLiftA) + harness.activeSessionEngine.saveSingleExerciseDefaults(singleA) + advanceUntilIdle() + + assertEquals(justLiftA, harness.activeSessionEngine.getJustLiftDefaults()) + assertEquals(singleA, harness.activeSessionEngine.getSingleExerciseDefaults("bench")) + + harness.fakeUserProfileRepo.setActiveProfileForTest(id = "profile-b") + advanceUntilIdle() + assertEquals(20f, harness.activeSessionEngine.getJustLiftDefaults().weightPerCableKg) + assertNull(harness.activeSessionEngine.getSingleExerciseDefaults("bench")) + + val readyB = assertIs( + harness.fakeUserProfileRepo.activeProfileContext.value, + ) + harness.fakeUserProfileRepo.updateWorkout( + readyB.profile.id, + readyB.preferences.workout.value.copy( + justLiftDefaults = readyB.preferences.workout.value.justLiftDefaults.copy( + weightPerCableKg = 18f, + weightChangePerRep = 2.6f, + ), + ), + ) + assertEquals(18f, harness.activeSessionEngine.getJustLiftDefaults().weightPerCableKg) + assertEquals(3, harness.activeSessionEngine.getJustLiftDefaults().weightChangePerRep) + + harness.fakeUserProfileRepo.setActiveProfileForTest(id = "default") + advanceUntilIdle() + assertEquals(justLiftA, harness.activeSessionEngine.getJustLiftDefaults()) + assertEquals(singleA, harness.activeSessionEngine.getSingleExerciseDefaults("bench")) + } finally { + harness.cleanup() + } + } + @Test fun bodyweightAndVbtFeaturesCoexistWithoutConstructorFailure() = runTest { val harness = DWSMTestHarness(this) advanceUntilIdle() // Enable both bodyweight volume tracking and VBT auto-end simultaneously - harness.fakePrefsManager.setPreferences( + harness.setActiveProfilePreferences( UserPreferences( bodyWeightKg = 85f, autoEndOnVelocityLoss = true, @@ -69,7 +156,7 @@ class ActiveSessionEngineIntegrationTest { advanceUntilIdle() // Set auto-start routine AND VBT thresholds together - harness.fakePrefsManager.setPreferences( + harness.setActiveProfilePreferences( UserPreferences( autoStartRoutine = true, autoStartCountdownSeconds = 3, @@ -106,7 +193,7 @@ class ActiveSessionEngineIntegrationTest { assertFalse(prefs.autoBackupEnabled, "autoBackupEnabled should default to false") // Set only autoStartRoutine — other features should remain at defaults - harness.fakePrefsManager.setAutoStartRoutine(true) + harness.settingsManager.setAutoStartRoutine(true) advanceUntilIdle() prefs = harness.settingsManager.userPreferences.value @@ -117,7 +204,7 @@ class ActiveSessionEngineIntegrationTest { assertFalse(prefs.autoBackupEnabled, "autoBackupEnabled unchanged") // Set only bodyWeightKg — autoStartRoutine should remain true - harness.fakePrefsManager.setBodyWeightKg(75f) + harness.setActiveBodyWeightKg(75f) advanceUntilIdle() prefs = harness.settingsManager.userPreferences.value @@ -142,7 +229,7 @@ class ActiveSessionEngineIntegrationTest { "Coordinator should reflect default VBT threshold", ) - harness.fakePrefsManager.setPreferences( + harness.setActiveProfilePreferences( UserPreferences( autoEndOnVelocityLoss = true, velocityLossThresholdPercent = 35, diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/BodyweightRackLoadTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/BodyweightRackLoadTest.kt index 58bf66abd..b4c5255c3 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/BodyweightRackLoadTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/BodyweightRackLoadTest.kt @@ -81,7 +81,7 @@ class BodyweightRackLoadTest { fun `Issue 534 - vest toggled before body-weight set is included in persisted volume`() = runTest { val harness = DWSMTestHarness(this) harness.fakeBleRepo.simulateConnect("Vee_Test") - harness.fakePrefsManager.setBodyWeightKg(80f) + harness.setActiveBodyWeightKg(80f) val v = vest() harness.fakeEquipmentRackRepo.upsert(v) @@ -141,7 +141,7 @@ class BodyweightRackLoadTest { fun `Issue 534 - vest toggled AFTER startWorkout on body-weight exercise is included in persisted volume`() = runTest { val harness = DWSMTestHarness(this) harness.fakeBleRepo.simulateConnect("Vee_Test") - harness.fakePrefsManager.setBodyWeightKg(80f) + harness.setActiveBodyWeightKg(80f) val v = vest() harness.fakeEquipmentRackRepo.upsert(v) @@ -210,7 +210,7 @@ class BodyweightRackLoadTest { fun `Issue 534 - vest configured as defaultRackItemIds seeds adjustment on enterSetReady`() = runTest { val harness = DWSMTestHarness(this) harness.fakeBleRepo.simulateConnect("Vee_Test") - harness.fakePrefsManager.setBodyWeightKg(80f) + harness.setActiveBodyWeightKg(80f) val v = vest() harness.fakeEquipmentRackRepo.upsert(v) diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/DWSMEquipmentRackTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/DWSMEquipmentRackTest.kt index 2308e3c72..023ae0cb7 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/DWSMEquipmentRackTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/DWSMEquipmentRackTest.kt @@ -287,7 +287,7 @@ class DWSMEquipmentRackTest { rackItem("assist", 10f, RackItemBehavior.COUNTERWEIGHT), ), ) - harness.fakePrefsManager.setSummaryCountdownSeconds(10) + harness.setActiveSummaryCountdownSeconds(10) val routine = Routine( id = "routine-issue-536-autoplay", name = "Vest Leak Autoplay Repro", @@ -379,7 +379,7 @@ class DWSMEquipmentRackTest { harness.activeSessionEngine.handleSetCompletion() advanceUntilIdle() - val defaults = harness.fakePrefsManager.getSingleExerciseDefaults(exerciseId) + val defaults = harness.activeSessionEngine.getSingleExerciseDefaults(exerciseId) assertEquals(listOf("vest"), defaults?.defaultRackItemIds) harness.cleanup() } diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/DWSMRoutineFlowTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/DWSMRoutineFlowTest.kt index 929e91d78..a403e4a72 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/DWSMRoutineFlowTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/DWSMRoutineFlowTest.kt @@ -1722,7 +1722,7 @@ class DWSMRoutineFlowTest { ), ) routine.exercises.forEach { harness.fakeExerciseRepo.addExercise(it.exercise) } - harness.fakePrefsManager.setSummaryCountdownSeconds(10) + harness.setActiveSummaryCountdownSeconds(10) harness.dwsm.loadRoutine(routine) advanceUntilIdle() @@ -1792,7 +1792,7 @@ class DWSMRoutineFlowTest { ), ) routine.exercises.forEach { harness.fakeExerciseRepo.addExercise(it.exercise) } - harness.fakePrefsManager.setSummaryCountdownSeconds(10) + harness.setActiveSummaryCountdownSeconds(10) harness.dwsm.loadRoutine(routine) advanceUntilIdle() @@ -1831,7 +1831,7 @@ class DWSMRoutineFlowTest { val harness = DWSMTestHarness(this) val routine = createLegsStyleRoutine() routine.exercises.forEach { harness.fakeExerciseRepo.addExercise(it.exercise) } - harness.fakePrefsManager.setSummaryCountdownSeconds(0) + harness.setActiveSummaryCountdownSeconds(0) harness.dwsm.loadRoutine(routine) advanceUntilIdle() diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/DWSMSessionBodyweightTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/DWSMSessionBodyweightTest.kt index 12d464968..2d840038d 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/DWSMSessionBodyweightTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/DWSMSessionBodyweightTest.kt @@ -168,7 +168,7 @@ class DWSMSessionBodyweightTest { val harness = DWSMTestHarness(this) try { seedExercises(harness) - harness.fakePrefsManager.setBodyWeightKg(82f) + harness.setActiveBodyWeightKg(82f) harness.dwsm.loadRoutine(bodyweightRoutine()) advanceUntilIdle() @@ -189,7 +189,7 @@ class DWSMSessionBodyweightTest { val harness = DWSMTestHarness(this) try { seedExercises(harness) - harness.fakePrefsManager.setBodyWeightKg(75f) + harness.setActiveBodyWeightKg(75f) harness.dwsm.loadRoutine(bodyweightRoutine()) advanceUntilIdle() @@ -200,7 +200,7 @@ class DWSMSessionBodyweightTest { assertEquals(SessionBodyweightAction.EDITED_FOR_SESSION, state.lastAction) assertFloatEquals(300f, state.sessionBodyWeightKg ?: 0f) assertFloatEquals(300f, harness.dwsm.resolvedBodyWeightKg()) - assertFloatEquals(75f, harness.fakePrefsManager.preferencesFlow.value.bodyWeightKg) + assertFloatEquals(75f, harness.settingsManager.userPreferences.value.bodyWeightKg) } finally { harness.cleanup() } @@ -211,7 +211,7 @@ class DWSMSessionBodyweightTest { val harness = DWSMTestHarness(this) try { seedExercises(harness) - harness.fakePrefsManager.setBodyWeightKg(75f) + harness.setActiveBodyWeightKg(75f) harness.dwsm.loadRoutine(bodyweightRoutine()) advanceUntilIdle() @@ -221,7 +221,7 @@ class DWSMSessionBodyweightTest { val state = harness.dwsm.sessionBodyweightState.value assertEquals(SessionBodyweightAction.EDITED_FOR_SESSION, state.lastAction) assertFloatEquals(20f, state.sessionBodyWeightKg ?: 0f) - assertFloatEquals(75f, harness.fakePrefsManager.preferencesFlow.value.bodyWeightKg) + assertFloatEquals(75f, harness.settingsManager.userPreferences.value.bodyWeightKg) } finally { harness.cleanup() } @@ -232,7 +232,7 @@ class DWSMSessionBodyweightTest { val harness = DWSMTestHarness(this) try { seedExercises(harness) - harness.fakePrefsManager.setBodyWeightKg(75f) + harness.setActiveBodyWeightKg(75f) harness.dwsm.loadRoutine(bodyweightRoutine()) advanceUntilIdle() @@ -242,7 +242,7 @@ class DWSMSessionBodyweightTest { val state = harness.dwsm.sessionBodyweightState.value assertEquals(SessionBodyweightAction.EDITED_AND_SAVED_TO_PROFILE, state.lastAction) assertFloatEquals(91f, state.sessionBodyWeightKg ?: 0f) - assertFloatEquals(91f, harness.fakePrefsManager.preferencesFlow.value.bodyWeightKg) + assertFloatEquals(91f, harness.settingsManager.userPreferences.value.bodyWeightKg) assertFloatEquals(91f, harness.dwsm.resolvedBodyWeightKg()) } finally { harness.cleanup() @@ -254,7 +254,7 @@ class DWSMSessionBodyweightTest { val harness = DWSMTestHarness(this) try { seedExercises(harness) - harness.fakePrefsManager.setBodyWeightKg(84f) + harness.setActiveBodyWeightKg(84f) harness.dwsm.loadRoutine(bodyweightRoutine()) advanceUntilIdle() @@ -298,7 +298,7 @@ class DWSMSessionBodyweightTest { try { seedExercises(harness) harness.fakeBleRepo.simulateConnect("Vee_Test") - harness.fakePrefsManager.setBodyWeightKg(80f) + harness.setActiveBodyWeightKg(80f) harness.dwsm.loadRoutine(bodyweightRoutine()) advanceUntilIdle() harness.dwsm.confirmSessionBodyWeight(weightKg = 90f, saveToProfile = false) @@ -322,7 +322,7 @@ class DWSMSessionBodyweightTest { try { seedExercises(harness) harness.fakeBleRepo.simulateConnect("Vee_Test") - harness.fakePrefsManager.setBodyWeightKg(80f) + harness.setActiveBodyWeightKg(80f) val v = vest() harness.fakeEquipmentRackRepo.upsert(v) advanceUntilIdle() diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/DWSMWorkoutLifecycleTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/DWSMWorkoutLifecycleTest.kt index bb4c33796..503581ff9 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/DWSMWorkoutLifecycleTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/DWSMWorkoutLifecycleTest.kt @@ -503,7 +503,7 @@ class DWSMWorkoutLifecycleTest { // Disable autoplay so startNextSet() enters SetReady instead of launching // startWorkout() which creates infinite delay loops (rest timer, metrics, etc.) - harness.fakePrefsManager.setSummaryCountdownSeconds(0) + harness.setActiveSummaryCountdownSeconds(0) advanceUntilIdle() harness.dwsm.coordinator._workoutState.value = WorkoutState.Resting( @@ -528,7 +528,7 @@ class DWSMWorkoutLifecycleTest { val harness = DWSMTestHarness(this) val routine = createTestRoutine(exerciseCount = 1, setsPerExercise = 2) routine.exercises.forEach { harness.fakeExerciseRepo.addExercise(it.exercise) } - harness.fakePrefsManager.setSummaryCountdownSeconds(0) + harness.setActiveSummaryCountdownSeconds(0) harness.dwsm.loadRoutine(routine) advanceUntilIdle() @@ -547,7 +547,7 @@ class DWSMWorkoutLifecycleTest { val harness = DWSMTestHarness(this) val routine = createTestRoutine(exerciseCount = 1, setsPerExercise = 2) routine.exercises.forEach { harness.fakeExerciseRepo.addExercise(it.exercise) } - harness.fakePrefsManager.setSummaryCountdownSeconds(0) + harness.setActiveSummaryCountdownSeconds(0) harness.dwsm.loadRoutine(routine) advanceUntilIdle() @@ -566,7 +566,7 @@ class DWSMWorkoutLifecycleTest { val harness = DWSMTestHarness(this) val routine = createTestRoutine(exerciseCount = 1, setsPerExercise = 2) routine.exercises.forEach { harness.fakeExerciseRepo.addExercise(it.exercise) } - harness.fakePrefsManager.setSummaryCountdownSeconds(0) + harness.setActiveSummaryCountdownSeconds(0) val events = mutableListOf() val hapticJob = launch(UnconfinedTestDispatcher(testScheduler)) { harness.dwsm.coordinator.hapticEvents.collect { event -> @@ -609,8 +609,8 @@ class DWSMWorkoutLifecycleTest { val harness = DWSMTestHarness(this) val routine = createTestRoutine(exerciseCount = 1, setsPerExercise = 2) routine.exercises.forEach { harness.fakeExerciseRepo.addExercise(it.exercise) } - harness.fakePrefsManager.setSummaryCountdownSeconds(0) - harness.fakePrefsManager.setCountdownBeepsEnabled(false) + harness.setActiveSummaryCountdownSeconds(0) + harness.setActiveCountdownBeepsEnabled(false) val events = mutableListOf() val hapticJob = launch(UnconfinedTestDispatcher(testScheduler)) { harness.dwsm.coordinator.hapticEvents.collect { event -> @@ -638,7 +638,7 @@ class DWSMWorkoutLifecycleTest { val harness = DWSMTestHarness(this) val routine = createTestRoutine(exerciseCount = 1, setsPerExercise = 2) routine.exercises.forEach { harness.fakeExerciseRepo.addExercise(it.exercise) } - harness.fakePrefsManager.setSummaryCountdownSeconds(0) + harness.setActiveSummaryCountdownSeconds(0) harness.dwsm.loadRoutine(routine) advanceUntilIdle() @@ -671,7 +671,7 @@ class DWSMWorkoutLifecycleTest { val harness = DWSMTestHarness(this) val routine = createTestRoutine(exerciseCount = 1, setsPerExercise = 2) routine.exercises.forEach { harness.fakeExerciseRepo.addExercise(it.exercise) } - harness.fakePrefsManager.setSummaryCountdownSeconds(0) + harness.setActiveSummaryCountdownSeconds(0) harness.dwsm.loadRoutine(routine) advanceUntilIdle() @@ -2196,7 +2196,7 @@ class DWSMWorkoutLifecycleTest { fun `Issue 427 - timed bodyweight set prompts for reps instead of saving zero volume`() = runTest { val harness = DWSMTestHarness(this) harness.fakeBleRepo.simulateConnect("Vee_Test") - harness.fakePrefsManager.setBodyWeightKg(80f) + harness.setActiveBodyWeightKg(80f) val routine = createBodyweightRoutine(sets = 3, repsPerSet = 10, durationSeconds = 1) routine.exercises.forEach { harness.fakeExerciseRepo.addExercise(it.exercise) } @@ -2236,7 +2236,7 @@ class DWSMWorkoutLifecycleTest { // the set; the prompt fires afterwards. val harness = DWSMTestHarness(this) harness.fakeBleRepo.simulateConnect("Vee_Test") - harness.fakePrefsManager.setBodyWeightKg(80f) + harness.setActiveBodyWeightKg(80f) val routine = createBodyweightRoutine(sets = 1, repsPerSet = 10, durationSeconds = 0) routine.exercises.forEach { harness.fakeExerciseRepo.addExercise(it.exercise) } @@ -2268,7 +2268,7 @@ class DWSMWorkoutLifecycleTest { fun `Issue 427 - confirming bodyweight reps saves selected variant volume`() = runTest { val harness = DWSMTestHarness(this) harness.fakeBleRepo.simulateConnect("Vee_Test") - harness.fakePrefsManager.setBodyWeightKg(80f) + harness.setActiveBodyWeightKg(80f) val routine = createBodyweightRoutine(sets = 3, repsPerSet = 10, durationSeconds = 1) routine.exercises.forEach { harness.fakeExerciseRepo.addExercise(it.exercise) } @@ -2342,7 +2342,7 @@ class DWSMWorkoutLifecycleTest { fun `Issue 427 - bodyweight variant carries into later set prompt`() = runTest { val harness = DWSMTestHarness(this) harness.fakeBleRepo.simulateConnect("Vee_Test") - harness.fakePrefsManager.setBodyWeightKg(80f) + harness.setActiveBodyWeightKg(80f) val routine = createBodyweightRoutine(sets = 3, repsPerSet = 10, durationSeconds = 1) routine.exercises.forEach { harness.fakeExerciseRepo.addExercise(it.exercise) } @@ -2381,7 +2381,7 @@ class DWSMWorkoutLifecycleTest { fun `Issue 490 - timed bodyweight set emits final countdown ticks`() = runTest { val harness = DWSMTestHarness(this) harness.fakeBleRepo.simulateConnect("Vee_Test") - harness.fakePrefsManager.setBodyWeightKg(80f) + harness.setActiveBodyWeightKg(80f) val routine = createBodyweightRoutine(sets = 1, repsPerSet = 10, durationSeconds = 12) routine.exercises.forEach { harness.fakeExerciseRepo.addExercise(it.exercise) } val ticks = mutableListOf() @@ -2413,7 +2413,7 @@ class DWSMWorkoutLifecycleTest { fun `Issue 490 - short timed bodyweight set includes initial countdown tick`() = runTest { val harness = DWSMTestHarness(this) harness.fakeBleRepo.simulateConnect("Vee_Test") - harness.fakePrefsManager.setBodyWeightKg(80f) + harness.setActiveBodyWeightKg(80f) val routine = createBodyweightRoutine(sets = 1, repsPerSet = 10, durationSeconds = 10) routine.exercises.forEach { harness.fakeExerciseRepo.addExercise(it.exercise) } val ticks = mutableListOf() @@ -2482,8 +2482,8 @@ class DWSMWorkoutLifecycleTest { fun `Issue 490 - disabled countdown beeps suppress timed bodyweight ticks`() = runTest { val harness = DWSMTestHarness(this) harness.fakeBleRepo.simulateConnect("Vee_Test") - harness.fakePrefsManager.setBodyWeightKg(80f) - harness.fakePrefsManager.setCountdownBeepsEnabled(false) + harness.setActiveBodyWeightKg(80f) + harness.setActiveCountdownBeepsEnabled(false) val routine = createBodyweightRoutine(sets = 1, repsPerSet = 10, durationSeconds = 12) routine.exercises.forEach { harness.fakeExerciseRepo.addExercise(it.exercise) } val ticks = mutableListOf() diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/VbtEnabledRuntimeTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/VbtEnabledRuntimeTest.kt new file mode 100644 index 000000000..3e5f8b2e4 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/VbtEnabledRuntimeTest.kt @@ -0,0 +1,388 @@ +package com.devil.phoenixproject.presentation.manager + +import com.devil.phoenixproject.data.repository.ActiveProfileContext +import com.devil.phoenixproject.domain.model.HapticEvent +import com.devil.phoenixproject.domain.model.RepCount +import com.devil.phoenixproject.domain.model.UserPreferences +import com.devil.phoenixproject.domain.model.WorkoutMetric +import com.devil.phoenixproject.domain.model.WorkoutState +import com.devil.phoenixproject.testutil.DWSMTestHarness +import com.devil.phoenixproject.testutil.readProjectFile +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest + +class VbtEnabledRuntimeTest { + + @Test + fun disabledVbtKeepsBiomechanicsAndPersistenceButSuppressesLiveInterpretation() = runTest { + val harness = DWSMTestHarness(this) + val events = mutableListOf() + val eventJob = launch(UnconfinedTestDispatcher(testScheduler)) { + harness.coordinator.hapticEvents.toList(events) + } + + try { + advanceUntilIdle() + harness.setActiveProfilePreferences( + UserPreferences( + vbtEnabled = false, + velocityLossThresholdPercent = 20, + autoEndOnVelocityLoss = true, + ), + ) + advanceUntilIdle() + + harness.coordinator._repCount.value = RepCount( + workingReps = 3, + totalReps = 3, + isWarmupComplete = true, + ) + harness.coordinator._workoutState.value = WorkoutState.Active + harness.coordinator.currentSessionId = "disabled-vbt-session" + + processRep(harness, repNumber = 1, velocityMmS = 100.0) + processRep(harness, repNumber = 2, velocityMmS = 70.0) + processRep(harness, repNumber = 3, velocityMmS = 60.0) + advanceUntilIdle() + + val latest = assertNotNull(harness.coordinator.biomechanicsEngine.latestRepResult.value) + assertTrue(latest.velocity.shouldStopSet) + assertEquals( + 3, + harness.coordinator.biomechanicsEngine.getSetSummary()?.repResults?.size, + "The VBT master must not gate raw biomechanics capture.", + ) + assertTrue( + events.none { it is HapticEvent.VELOCITY_THRESHOLD_REACHED }, + "Disabled VBT must suppress velocity-threshold feedback.", + ) + assertTrue( + events.none { it is HapticEvent.VERBAL_ENCOURAGEMENT }, + "Disabled VBT must suppress verbal feedback.", + ) + assertIs( + harness.coordinator.workoutState.value, + "Disabled VBT must not auto-end the active set.", + ) + + harness.activeSessionEngine.handleSetCompletion() + advanceUntilIdle() + + assertEquals( + 3, + harness.fakeBiomechanicsRepo.savedBiomechanics["disabled-vbt-session"]?.size, + "Completing a disabled-VBT set must still persist every biomechanics result.", + ) + } finally { + eventJob.cancel() + harness.cleanup() + } + } + + @Test + fun reEnablingRestoresFeedbackAndAutoEndWithoutChangingSubordinateConfiguration() = runTest { + val harness = DWSMTestHarness(this) + val events = mutableListOf() + val eventJob = launch(UnconfinedTestDispatcher(testScheduler)) { + harness.coordinator.hapticEvents.toList(events) + } + + try { + advanceUntilIdle() + harness.setActiveProfilePreferences( + UserPreferences( + vbtEnabled = false, + velocityLossThresholdPercent = 20, + autoEndOnVelocityLoss = true, + ), + ) + advanceUntilIdle() + harness.coordinator._repCount.value = RepCount(isWarmupComplete = true) + harness.coordinator._workoutState.value = WorkoutState.Active + harness.coordinator.currentSessionId = "re-enabled-vbt-session" + + processRep(harness, repNumber = 1, velocityMmS = 100.0) + processRep(harness, repNumber = 2, velocityMmS = 70.0) + assertTrue(events.none { it is HapticEvent.VELOCITY_THRESHOLD_REACHED }) + + harness.settingsManager.setVbtEnabled(true) + advanceUntilIdle() + + assertEquals(20f, harness.coordinator.biomechanicsEngine.currentVelocityLossThresholdPercent) + assertTrue(harness.coordinator.autoEndOnVelocityLoss) + + processRep(harness, repNumber = 3, velocityMmS = 60.0) + assertTrue(events.any { it is HapticEvent.VELOCITY_THRESHOLD_REACHED }) + assertIs(harness.coordinator.workoutState.value) + + processRep(harness, repNumber = 4, velocityMmS = 50.0) + advanceUntilIdle() + assertTrue( + harness.coordinator.workoutState.value !is WorkoutState.Active, + "Two enabled threshold reps must restore the existing auto-end behavior.", + ) + } finally { + eventJob.cancel() + harness.cleanup() + } + } + + @Test + fun readyProfileSwitchPublishesOnlyCoherentVbtTriples() = runTest { + val harness = DWSMTestHarness(this) + val profileA = VbtRuntimeSettings( + enabled = false, + velocityLossThresholdPercent = 15f, + autoEndOnVelocityLoss = true, + ) + val profileB = VbtRuntimeSettings( + enabled = true, + velocityLossThresholdPercent = 35f, + autoEndOnVelocityLoss = false, + ) + + try { + advanceUntilIdle() + harness.setActiveProfilePreferences( + UserPreferences( + vbtEnabled = profileA.enabled, + velocityLossThresholdPercent = profileA.velocityLossThresholdPercent.toInt(), + autoEndOnVelocityLoss = profileA.autoEndOnVelocityLoss, + ), + ) + advanceUntilIdle() + + harness.fakeUserProfileRepo.setActiveProfileForTest(id = "profile-b") + harness.setActiveProfilePreferences( + UserPreferences( + vbtEnabled = profileB.enabled, + velocityLossThresholdPercent = profileB.velocityLossThresholdPercent.toInt(), + autoEndOnVelocityLoss = profileB.autoEndOnVelocityLoss, + ), + ) + advanceUntilIdle() + harness.fakeUserProfileRepo.setActiveProfileForTest(id = "default") + advanceUntilIdle() + assertEquals(profileA, harness.coordinator.vbtRuntimeSettings.value) + + val observed = mutableListOf() + val collector = launch(UnconfinedTestDispatcher(testScheduler)) { + harness.coordinator.vbtRuntimeSettings.toList(observed) + } + harness.fakeUserProfileRepo.setActiveProfile("profile-b") + advanceUntilIdle() + + assertEquals(profileB, harness.coordinator.vbtRuntimeSettings.value) + assertTrue( + observed.all { it == profileA || it == profileB }, + "A profile switch must never expose an enabled/threshold/auto-end mix.", + ) + collector.cancel() + } finally { + harness.cleanup() + } + } + + @Test + fun localAdultConfirmationNeutralizesRuntimeFeedbackWithoutMutatingProfileIntent() = runTest { + val harness = DWSMTestHarness(this) + val events = mutableListOf() + val eventJob = launch(UnconfinedTestDispatcher(testScheduler)) { + harness.coordinator.hapticEvents.toList(events) + } + + try { + advanceUntilIdle() + harness.setActiveProfilePreferences( + UserPreferences( + vbtEnabled = true, + velocityLossThresholdPercent = 20, + beepsEnabled = true, + verbalEncouragementEnabled = true, + vulgarModeEnabled = true, + dominatrixModeUnlocked = true, + dominatrixModeActive = true, + adultsOnlyConfirmed = false, + ), + ) + advanceUntilIdle() + harness.coordinator._repCount.value = RepCount(isWarmupComplete = true) + harness.coordinator._workoutState.value = WorkoutState.Active + + processRep(harness, repNumber = 1, velocityMmS = 100.0) + processRep(harness, repNumber = 2, velocityMmS = 70.0) + + val verbal = events.filterIsInstance().single() + assertEquals(false, verbal.vulgarMode) + assertEquals(false, verbal.dominatrixMode) + + val ready = assertIs( + harness.fakeUserProfileRepo.activeProfileContext.value, + ) + assertTrue(ready.preferences.vbt.value.vulgarModeEnabled) + assertTrue(ready.preferences.vbt.value.dominatrixModeActive) + } finally { + eventJob.cancel() + harness.cleanup() + } + } + + private suspend fun processRep( + harness: DWSMTestHarness, + repNumber: Int, + velocityMmS: Double, + ) { + val metrics = List(4) { index -> + WorkoutMetric( + timestamp = repNumber * 1_000L + index, + loadA = 20f, + loadB = 20f, + positionA = index * 50f, + positionB = index * 50f, + velocityA = velocityMmS, + velocityB = velocityMmS, + ) + } + harness.coordinator.biomechanicsEngine.processRep( + repNumber = repNumber, + concentricMetrics = metrics, + allRepMetrics = metrics, + timestamp = repNumber * 1_000L, + ) + harness.activeSessionEngine.evaluateLatestVbtResult() + } +} + +class VbtUiWiringTest { + + @Test + fun activeWorkoutThreadsTheProfileMasterThroughEveryComposeLayer() { + val state = source("WorkoutUiState.kt") + val screen = source("ActiveWorkoutScreen.kt") + val tab = source("WorkoutTab.kt") + val hud = source("WorkoutHud.kt") + + assertTrue(state.contains("val vbtEnabled: Boolean = true")) + val rememberBlock = screen.substringAfter("val workoutUiState = remember(") + .substringBefore(") {\n WorkoutUiState(") + assertTrue(rememberBlock.contains("userPreferences.vbtEnabled")) + assertTrue(screen.contains("vbtEnabled = userPreferences.vbtEnabled,")) + + val outerTab = tab.substringAfter("fun WorkoutTab(\n state: WorkoutUiState") + .substringBefore("@Suppress(\"SENSELESS_COMPARISON\")") + assertTrue(outerTab.contains("vbtEnabled = state.vbtEnabled,")) + val innerTab = tab.substringAfter("@Suppress(\"SENSELESS_COMPARISON\")") + assertTrue(innerTab.contains("vbtEnabled: Boolean = true")) + assertTrue(innerTab.contains("vbtEnabled = vbtEnabled,")) + + assertTrue(hud.contains("vbtEnabled: Boolean = true")) + assertTrue(hud.contains("vbtEnabled = vbtEnabled,")) + } + + @Test + fun disabledUiHidesOnlyLiveInterpretationAndLeavesHistoryAndRawVelocityVisible() { + val hud = source("WorkoutHud.kt") + val history = readProjectFile( + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/BiomechanicsHistoryCard.kt", + ) + assertNotNull(history) + + assertVbtStatsStructure(hud.substringAfter("private fun StatsPage(")) + assertTrue(!hud.contains("if (vbtEnabled && latestBiomechanicsResult != null)")) + assertTrue(!history.contains("vbtEnabled"), "Historical biomechanics must remain ungated.") + } + + @Test + fun statsSourceCheckerRejectsRawMetricsInsideMasterGateAndEscapedRepsLeft() { + val malformed = """ + val zColor = if (vbtEnabled) { activeColor } else { neutralColor } + StatColumn(label = "MCV") + if (vbtEnabled) { + StatColumn(label = "Zone") + StatColumn(label = "Peak") + } + val vloss = result.velocityLossPercent + if (vbtEnabled && vloss != null) { + VelocityLossIndicator() + } + StatColumn(label = "Est. Reps Left") + """.trimIndent() + + assertFailsWith { assertVbtStatsStructure(malformed) } + } + + private fun assertVbtStatsStructure(statsSource: String) { + assertTrue(statsSource.contains("val zColor = if (vbtEnabled)")) + + val zoneGate = blocksFor(statsSource, "if (vbtEnabled) {") + .singleOrNull { it.contains("label = \"Zone\"") } + assertNotNull(zoneGate, "Zone must have one explicit VBT gate.") + assertTrue(zoneGate.contains("label = \"Zone\""), "Zone must be inside its VBT gate.") + assertTrue(!zoneGate.contains("label = \"MCV\""), "Raw MCV must remain outside the VBT gate.") + assertTrue(!zoneGate.contains("label = \"Peak\""), "Raw Peak must remain outside the VBT gate.") + + val lossHeader = "if (vbtEnabled && vloss != null) {" + val lossGateStart = statsSource.indexOf(lossHeader) + assertTrue(lossGateStart >= 0, "Velocity-loss interpretation must have a master gate.") + val lossGate = blockFor(statsSource, lossHeader) + assertTrue(lossGate.contains("VelocityLossIndicator("), "Velocity loss must stay inside its gate.") + assertTrue(lossGate.contains("label = \"Est. Reps Left\""), "Estimated reps must stay inside the loss gate.") + assertTrue(!lossGate.contains("label = \"MCV\""), "Raw MCV must not move into the loss gate.") + assertTrue(!lossGate.contains("label = \"Peak\""), "Raw Peak must not move into the loss gate.") + + val mcvIndex = statsSource.indexOf("label = \"MCV\"") + val peakIndex = statsSource.indexOf("label = \"Peak\"") + assertTrue(mcvIndex in 0 until lossGateStart, "Raw MCV must render before the loss gate.") + assertTrue(peakIndex in 0 until lossGateStart, "Raw Peak must render before the loss gate.") + } + + private fun blockFor(source: String, header: String): String { + val headerStart = source.indexOf(header) + assertTrue(headerStart >= 0, "Missing source gate: $header") + return blockFor(source, header, headerStart) + } + + private fun blocksFor(source: String, header: String): List { + val blocks = mutableListOf() + var searchFrom = 0 + while (true) { + val headerStart = source.indexOf(header, startIndex = searchFrom) + if (headerStart < 0) return blocks + val block = blockFor(source, header, headerStart) + blocks += block + searchFrom = headerStart + block.length + } + } + + private fun blockFor(source: String, header: String, headerStart: Int): String { + val openingBrace = source.indexOf('{', startIndex = headerStart) + var depth = 0 + for (index in openingBrace until source.length) { + when (source[index]) { + '{' -> depth++ + '}' -> { + depth-- + if (depth == 0) return source.substring(headerStart, index + 1) + } + } + } + throw AssertionError("Unclosed source gate: $header") + } + + private fun source(fileName: String): String { + val source = readProjectFile( + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/$fileName", + ) + assertNotNull(source, "Could not locate $fileName") + return source + } +} diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/VerbalEncouragementGateTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/VerbalEncouragementGateTest.kt index 0ef72f734..d4c896e14 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/VerbalEncouragementGateTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/VerbalEncouragementGateTest.kt @@ -1,172 +1,111 @@ package com.devil.phoenixproject.presentation.manager -import com.devil.phoenixproject.domain.model.HapticEvent import com.devil.phoenixproject.domain.model.UserPreferences import com.devil.phoenixproject.domain.model.VulgarTier import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue -/** - * Issue #611: Verbal encouragement emission gate tests. - * - * Mirrors the velocity-threshold state machine from VbtAutoEndTest but adds - * the verbal encouragement event emission logic. The test-local tracker - * isolates the logic from the 17+ dependency ActiveSessionEngine so we can - * verify the gate and tier routing independently. - * - * Spec: implementation-spec.md §2 Packet A "Tests required" + architecture.md - * §13.1 VerbalEncouragementGateTest. - */ -private class VerbalEncouragementTracker( - private val prefs: () -> UserPreferences, -) { - var alertEmitted = false - private set - val events = mutableListOf() - - fun onRepCompleted(shouldStopSet: Boolean) { - if (shouldStopSet && !alertEmitted) { - alertEmitted = true - events.add(HapticEvent.VELOCITY_THRESHOLD_REACHED) - val current = prefs() - if (current.beepsEnabled && current.verbalEncouragementEnabled && current.vulgarModeEnabled) { - events.add( - HapticEvent.VERBAL_ENCOURAGEMENT( - vulgarTier = current.vulgarTier, - dominatrixMode = current.dominatrixModeActive, - ), - ) - } - } - } -} - +/** Production-policy tests for the VBT threshold verbal-feedback gate. */ class VerbalEncouragementGateTest { @Test - fun `verbal encouragement suppressed when beeps disabled`() { - val prefs = UserPreferences( - beepsEnabled = false, - verbalEncouragementEnabled = true, - vulgarModeEnabled = true, - vulgarTier = VulgarTier.STRONG, + fun `verbal encouragement is suppressed when beeps are disabled`() { + assertNull( + UserPreferences( + beepsEnabled = false, + verbalEncouragementEnabled = true, + ).verbalEncouragementEventOrNull(), ) - val tracker = VerbalEncouragementTracker(prefs = { prefs }) - - tracker.onRepCompleted(shouldStopSet = true) - - assertEquals(1, tracker.events.size, "Only VELOCITY_THRESHOLD_REACHED") - assertTrue(tracker.events[0] is HapticEvent.VELOCITY_THRESHOLD_REACHED) } @Test - fun `verbal encouragement suppressed when master toggle off`() { - val prefs = UserPreferences( - beepsEnabled = true, - verbalEncouragementEnabled = false, - vulgarModeEnabled = true, - vulgarTier = VulgarTier.STRONG, + fun `verbal encouragement is suppressed when its master is disabled`() { + assertNull( + UserPreferences( + beepsEnabled = true, + verbalEncouragementEnabled = false, + ).verbalEncouragementEventOrNull(), ) - val tracker = VerbalEncouragementTracker(prefs = { prefs }) - - tracker.onRepCompleted(shouldStopSet = true) - - assertEquals(1, tracker.events.size, "Only VELOCITY_THRESHOLD_REACHED") - assertTrue(tracker.events[0] is HapticEvent.VELOCITY_THRESHOLD_REACHED) } @Test - fun `verbal encouragement emits STRONG tier when vulgar on`() { - val prefs = UserPreferences( - beepsEnabled = true, - verbalEncouragementEnabled = true, - vulgarModeEnabled = true, - vulgarTier = VulgarTier.STRONG, - dominatrixModeActive = false, + fun `missing adult confirmation routes persisted vulgar intent to neutral audio`() { + val event = assertNotNull( + UserPreferences( + beepsEnabled = true, + verbalEncouragementEnabled = true, + vulgarModeEnabled = true, + vulgarTier = VulgarTier.MIX, + dominatrixModeUnlocked = true, + dominatrixModeActive = true, + adultsOnlyConfirmed = false, + ).verbalEncouragementEventOrNull(), ) - val tracker = VerbalEncouragementTracker(prefs = { prefs }) - tracker.onRepCompleted(shouldStopSet = true) - - assertEquals(2, tracker.events.size) - val verbal = tracker.events[1] as HapticEvent.VERBAL_ENCOURAGEMENT - assertEquals(VulgarTier.STRONG, verbal.vulgarTier) - assertFalse(verbal.dominatrixMode) + assertEquals(VulgarTier.MIX, event.vulgarTier) + assertFalse(event.vulgarMode) + assertFalse(event.dominatrixMode) } @Test - fun `verbal encouragement emits MILD tier when vulgar on`() { - val prefs = UserPreferences( - beepsEnabled = true, - verbalEncouragementEnabled = true, - vulgarModeEnabled = true, - vulgarTier = VulgarTier.MILD, - dominatrixModeActive = false, + fun `adult confirmed vulgar intent keeps selected tier`() { + val event = assertNotNull( + UserPreferences( + beepsEnabled = true, + verbalEncouragementEnabled = true, + vulgarModeEnabled = true, + vulgarTier = VulgarTier.MILD, + adultsOnlyConfirmed = true, + ).verbalEncouragementEventOrNull(), ) - val tracker = VerbalEncouragementTracker(prefs = { prefs }) - - tracker.onRepCompleted(shouldStopSet = true) - val verbal = tracker.events[1] as HapticEvent.VERBAL_ENCOURAGEMENT - assertEquals(VulgarTier.MILD, verbal.vulgarTier) - assertFalse(verbal.dominatrixMode) + assertEquals(VulgarTier.MILD, event.vulgarTier) + assertTrue(event.vulgarMode) + assertFalse(event.dominatrixMode) } @Test - fun `verbal encouragement emits MIX tier when vulgar on`() { - val prefs = UserPreferences( - beepsEnabled = true, - verbalEncouragementEnabled = true, - vulgarModeEnabled = true, - vulgarTier = VulgarTier.MIX, - dominatrixModeActive = false, + fun `dominatrix routing requires adult vulgar unlocked and active`() { + val locked = assertNotNull( + UserPreferences( + beepsEnabled = true, + verbalEncouragementEnabled = true, + vulgarModeEnabled = true, + dominatrixModeUnlocked = false, + dominatrixModeActive = true, + adultsOnlyConfirmed = true, + ).verbalEncouragementEventOrNull(), ) - val tracker = VerbalEncouragementTracker(prefs = { prefs }) - - tracker.onRepCompleted(shouldStopSet = true) - - val verbal = tracker.events[1] as HapticEvent.VERBAL_ENCOURAGEMENT - assertEquals(VulgarTier.MIX, verbal.vulgarTier) - assertFalse(verbal.dominatrixMode) - } - - @Test - fun `dominatrix mode forces dominatrix flag regardless of vulgarTier`() { - val prefs = UserPreferences( - beepsEnabled = true, - verbalEncouragementEnabled = true, - vulgarModeEnabled = true, - vulgarTier = VulgarTier.STRONG, - dominatrixModeActive = true, + assertFalse(locked.dominatrixMode) + + val enabled = assertNotNull( + UserPreferences( + beepsEnabled = true, + verbalEncouragementEnabled = true, + vulgarModeEnabled = true, + dominatrixModeUnlocked = true, + dominatrixModeActive = true, + adultsOnlyConfirmed = true, + ).verbalEncouragementEventOrNull(), ) - val tracker = VerbalEncouragementTracker(prefs = { prefs }) - - tracker.onRepCompleted(shouldStopSet = true) - - val verbal = tracker.events[1] as HapticEvent.VERBAL_ENCOURAGEMENT - assertEquals(VulgarTier.STRONG, verbal.vulgarTier, "Tier carries STRONG") - assertTrue(verbal.dominatrixMode, "Dominatrix flag set, router picks dominatrix pool") + assertTrue(enabled.dominatrixMode) } @Test - fun `verbal encouragement emitted only once per set alongside VELOCITY_THRESHOLD_REACHED`() { - val prefs = UserPreferences( - beepsEnabled = true, - verbalEncouragementEnabled = true, - vulgarModeEnabled = true, - vulgarTier = VulgarTier.STRONG, + fun `non vulgar encouragement still emits through the neutral pool`() { + val event = assertNotNull( + UserPreferences( + beepsEnabled = true, + verbalEncouragementEnabled = true, + vulgarModeEnabled = false, + ).verbalEncouragementEventOrNull(), ) - val tracker = VerbalEncouragementTracker(prefs = { prefs }) - tracker.onRepCompleted(shouldStopSet = true) - tracker.onRepCompleted(shouldStopSet = true) - tracker.onRepCompleted(shouldStopSet = true) - - // One threshold alert + one verbal encouragement, even with multiple threshold reps. - assertEquals(2, tracker.events.size) - assertTrue(tracker.events[0] is HapticEvent.VELOCITY_THRESHOLD_REACHED) - assertTrue(tracker.events[1] is HapticEvent.VERBAL_ENCOURAGEMENT) + assertFalse(event.vulgarMode) + assertFalse(event.dominatrixMode) } -} \ No newline at end of file +} diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/WeightRecommendationIntegrationTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/WeightRecommendationIntegrationTest.kt index 020b80b40..23a9a4072 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/WeightRecommendationIntegrationTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/WeightRecommendationIntegrationTest.kt @@ -191,7 +191,7 @@ class WeightRecommendationIntegrationTest { expectActive: Boolean = true, ): DWSMTestHarness { val harness = DWSMTestHarness(this) - harness.fakePrefsManager.setPreferences(preferences) + harness.setActiveProfilePreferences(preferences) harness.fakeBleRepo.simulateConnect("Vee_Test") routine.exercises.forEach { harness.fakeExerciseRepo.addExercise(it.exercise) } harness.dwsm.loadRoutine(routine) diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/navigation/AssessmentProfileOwnershipTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/navigation/AssessmentProfileOwnershipTest.kt new file mode 100644 index 000000000..bd446645f --- /dev/null +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/navigation/AssessmentProfileOwnershipTest.kt @@ -0,0 +1,93 @@ +package com.devil.phoenixproject.presentation.navigation + +import com.devil.phoenixproject.data.repository.ActiveProfileContext +import com.devil.phoenixproject.testutil.FakeUserProfileRepository +import com.devil.phoenixproject.testutil.readProjectFile +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull + +class AssessmentProfileOwnershipTest { + @Test + fun `route owned by A invalidates through switching and Ready B without binding B`() { + val states = listOf( + ready("athlete-a"), + ActiveProfileContext.Switching("athlete-b"), + ready("athlete-b"), + ).map { context -> + resolveAssessmentProfileDestination( + routeProfileId = "athlete-a", + context = context, + ) + } + + assertEquals( + listOf( + AssessmentProfileDestinationState.Bound("athlete-a"), + AssessmentProfileDestinationState.Invalidated, + AssessmentProfileDestinationState.Invalidated, + ), + states, + ) + assertFalse( + states.any { it == AssessmentProfileDestinationState.Bound("athlete-b") }, + ) + } + + @Test + fun `all assessment entry points pass Ready profile IDs into route factories`() { + val navGraph = readProjectFile( + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/navigation/NavGraph.kt", + ) + val analytics = readProjectFile( + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/AnalyticsScreen.kt", + ) + val detail = readProjectFile( + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailScreen.kt", + ) + assertNotNull(navGraph) + assertNotNull(analytics) + assertNotNull(detail) + + assertContains( + navGraph, + "NavigationRoutes.StrengthAssessmentPicker.createRoute(profileId)", + ) + assertContains( + navGraph, + "NavigationRoutes.StrengthAssessment.createRoute(profileId, exerciseId)", + ) + assertContains( + navGraph, + "val activeProfileContext by profileRepository.activeProfileContext.collectAsState()", + ) + assertFalse(navGraph.contains("navigate(NavigationRoutes.StrengthAssessmentPicker.route)")) + assertFalse(navGraph.contains("ownerProfileId")) + assertContains(analytics, "assessmentProfileId: String?") + assertContains(analytics, "onNavigateToStrengthAssessment: (String) -> Unit") + assertContains(detail, "assessmentProfileId: String?") + assertContains(detail, "onNavigateToStrengthAssessment: (String) -> Unit") + assertFalse(detail.contains("NavigationRoutes.StrengthAssessment")) + } + + @Test + fun `analytics disables assessment entry until a Ready profile id exists`() { + val analytics = assertNotNull( + readProjectFile( + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/AnalyticsScreen.kt", + ), + ) + + assertContains(analytics, "assessmentEnabled: Boolean") + assertContains(analytics, "enabled = assessmentEnabled") + assertContains(analytics, "assessmentEnabled = assessmentProfileId != null") + } + + private fun ready(profileId: String): ActiveProfileContext.Ready { + val repository = FakeUserProfileRepository() + repository.setActiveProfileForTest(profileId) + return repository.activeProfileContext.value as ActiveProfileContext.Ready + } +} diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/navigation/NavigationRoutesTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/navigation/NavigationRoutesTest.kt index 93381db14..8cef19637 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/navigation/NavigationRoutesTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/navigation/NavigationRoutesTest.kt @@ -2,6 +2,7 @@ package com.devil.phoenixproject.presentation.navigation import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith class NavigationRoutesTest { @Test @@ -19,4 +20,29 @@ class NavigationRoutesTest { NavigationRoutes.SingleExerciseForExercise.createRoute("exercise/with space?"), ) } + + @Test + fun strengthAssessmentRoutes_encodeImmutableProfileOwnership() { + assertEquals( + "strength_assessment_picker/athlete%2FA", + NavigationRoutes.StrengthAssessmentPicker.createRoute("athlete/A"), + ) + assertEquals( + "strength_assessment/athlete%2FA/bench%20press", + NavigationRoutes.StrengthAssessment.createRoute("athlete/A", "bench press"), + ) + } + + @Test + fun strengthAssessmentRoutes_rejectBlankOwnership() { + assertFailsWith { + NavigationRoutes.StrengthAssessmentPicker.createRoute(" ") + } + assertFailsWith { + NavigationRoutes.StrengthAssessment.createRoute(" ", "bench") + } + assertFailsWith { + NavigationRoutes.StrengthAssessment.createRoute("athlete-a", " ") + } + } } diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/navigation/ProfileNavigationContractTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/navigation/ProfileNavigationContractTest.kt new file mode 100644 index 000000000..3e908b08e --- /dev/null +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/navigation/ProfileNavigationContractTest.kt @@ -0,0 +1,247 @@ +package com.devil.phoenixproject.presentation.navigation + +import com.devil.phoenixproject.testutil.readProjectFile +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ProfileNavigationContractTest { + private val routes = source( + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/navigation/NavigationRoutes.kt", + ) + private val main = source( + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/EnhancedMainScreen.kt", + ) + private val graph = source( + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/navigation/NavGraph.kt", + ) + private val dialogs = source( + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileDialogs.kt", + ) + private val switcherSheet = source( + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileSwitcherSheet.kt", + ) + private val tags = source( + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/util/TestTags.kt", + ) + private val profileScreen = source( + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ProfileScreen.kt", + ) + private val justLift = source( + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/JustLiftScreen.kt", + ) + + @Test + fun `one Profile route and the exact centered Home BottomNavItem contract`() { + assertEquals( + 1, + Regex("""object\s+Profile\s*:\s*NavigationRoutes\("profile"\)""") + .findAll(routes) + .count(), + ) + val enumBody = bracedBlockFrom(routes, "enum class BottomNavItem") + val entries = Regex("""(?m)^\s*([A-Z_]+)\s*\(""") + .findAll(enumBody) + .map { it.groupValues[1] } + .toList() + + assertEquals( + listOf("ANALYTICS", "INSIGHTS", "HOME", "PROFILE", "SETTINGS"), + entries, + ) + assertEquals(5, entries.size) + assertContains(enumBody, "HOME(NavigationRoutes.Home.route)") + assertFalse(enumBody.contains("WORKOUT")) + assertFalse(main.contains("BottomNavItem.WORKOUT")) + + listOf( + "BottomNavItem.HOME -> Icons.Default.Home", + "BottomNavItem.HOME -> homeContentDescription", + "BottomNavItem.HOME -> isHomeRoute", + "BottomNavItem.HOME -> com.devil.phoenixproject.presentation.util.TestTags.NAV_HOME", + "BottomNavItem.HOME -> onHomeClick", + ).forEach { branch -> assertContains(main, branch) } + assertContains(main, "homeContentDescription = stringResource(Res.string.cd_home)") + assertContains(main, "onHomeClick = {") + + val bottomBar = bracedBlockFrom(main, "bottomBar =") + assertContains( + bottomBar, + "insightsContentDescription = stringResource(Res.string.nav_insights)", + ) + assertFalse(bottomBar.contains("Res.string.insights_title")) + + mapOf( + "values" to ("Home" to "Insights"), + "values-de" to ("Startseite" to "Einblicke"), + "values-es" to ("Inicio" to "Perspectivas"), + "values-nl" to ("Startpagina" to "Inzichten"), + "values-fr" to ("Accueil" to "Analyses"), + ).forEach { (directory, labels) -> + val strings = source("src/commonMain/composeResources/$directory/strings.xml") + assertContains(strings, "${labels.first}") + assertContains(strings, "${labels.second}") + } + } + + @Test + fun `five canonical cells fit compact width with equal accessible targets`() { + val iteration = bracedBlockFrom(main, "BottomNavItem.entries.forEach") + + assertContains(main, ".padding(horizontal = 8.dp)") + assertContains(main, "Arrangement.spacedBy(4.dp)") + assertContains(main, ".selectableGroup()") + assertEquals(1, Regex("""\.weight\(1f\)""").findAll(iteration).count()) + assertContains(iteration, "PhoenixBottomNavigationItem(") + assertContains(main, ".heightIn(min = 48.dp)") + assertFalse(main.contains("widthIn(min = 64.dp")) + } + + @Test + fun `merged tab semantics preserve all five stable navigation tags`() { + val item = bracedBlockFrom(main, "private fun PhoenixBottomNavigationItem(") + val clearIndex = item.indexOf(".clearAndSetSemantics") + val tagIndex = item.indexOf(".testTag(testTag)") + + assertContains(main, ".selectableGroup()") + assertContains(item, ".combinedClickable(") + assertContains(item, "role = Role.Tab") + assertContains(item, "this.selected = selected") + assertContains(item, "this.onClick(label = contentDescription)") + assertContains(item, "this.onLongClick(label = longClickLabel)") + assertTrue(clearIndex >= 0 && tagIndex > clearIndex) + assertFalse( + Regex("""Modifier\s*\.\s*weight\(1f\)\s*\.\s*testTag\(""") + .containsMatchIn(main), + ) + listOf( + "NAV_ANALYTICS", + "NAV_INSIGHTS", + "NAV_HOME", + "NAV_PROFILE", + "NAV_SETTINGS", + ).forEach { tag -> + assertEquals(1, Regex("""const val $tag\b""").findAll(tags).count(), tag) + assertContains(main, "TestTags.$tag") + } + assertFalse(tags.contains("NAV_WORKOUTS")) + assertFalse(main.contains("NAV_WORKOUTS")) + } + + @Test + fun `Profile pointer and semantic long press haptics open only the switcher`() { + val longPress = bracedBlockFrom(main, "onProfileLongClick =") + val item = bracedBlockFrom(main, "private fun PhoenixBottomNavigationItem(") + + assertContains(main, "HapticFeedbackType.LongPress") + assertContains(longPress, "performHapticFeedback(HapticFeedbackType.LongPress)") + assertContains(longPress, "profileSwitcherViewModel.openSwitcher()") + assertFalse(longPress.contains("navController.navigate")) + assertFalse(longPress.contains("openAddDialog")) + assertContains(main, "BottomNavItem.PROFILE -> onProfileLongClick") + assertContains(main, "onLongClick = itemLongClick") + assertContains(item, "this.onLongClick(label = longClickLabel)") + } + + @Test + fun `normal Profile tap preserves root state and selection`() { + val tap = bracedBlockFrom(main, "onProfileClick =") + val bottomBarVisibility = bracedBlockFrom(main, "val shouldShowBottomBar") + + assertContains(tap, "navController.navigate(NavigationRoutes.Profile.route)") + assertContains(tap, "popUpTo(NavigationRoutes.Home.route) { saveState = true }") + assertContains(tap, "launchSingleTop = true") + assertContains(tap, "restoreState = true") + assertContains(bottomBarVisibility, "NavigationRoutes.Profile.route") + assertTrue( + Regex( + """BottomNavItem\.PROFILE\s*->\s*currentRoute\s*==\s*NavigationRoutes\.Profile\.route""", + ).containsMatchIn(main), + ) + } + + @Test + fun `NavGraph registers Profile callbacks and localized destination titles`() { + assertContains(graph, "route = NavigationRoutes.Profile.route") + assertContains(graph, "ProfileScreen(") + assertContains(graph, "onOpenProfileSwitcher = onOpenProfileSwitcher") + assertContains(graph, "onProfileRecoveryRequired = onProfileRecoveryRequired") + assertContains(graph, "Res.string.nav_profile") + assertContains(main, "val profileTitle = stringResource(Res.string.nav_profile)") + assertContains(main, "profileTitle = profileTitle") + assertContains(main, "NavigationRoutes.Profile.route -> profileTitle") + assertEquals(1, Regex("""const val SCREEN_PROFILE\b""").findAll(tags).count()) + assertContains(profileScreen, ".testTag(TestTags.SCREEN_PROFILE)") + assertFalse(main.contains("const val SCREEN_PROFILE")) + } + + @Test + fun `root overlays expose in-flight inline errors and blocking recovery`() { + assertContains(main, "val switchingInFlight") + assertContains(main, "repositorySwitching || localOperationInFlight") + assertContains(main, "val switchingTargetProfileId") + assertContains(main, "switchingInFlight = switchingInFlight") + assertContains(main, "switchingTargetProfileId = switchingTargetProfileId") + assertContains(main, "Res.string.profile_switch_failed") + assertContains(main, "Res.string.profile_create_failed") + assertContains(main, "Res.string.profile_recovery_retry_failed") + assertContains(main, "ProfileRecoveryDialog(") + assertContains(switcherSheet, "switchingInFlight: Boolean") + assertContains(main, "isSubmitting = switchingInFlight") + assertContains(dialogs, "errorMessage: String?") + assertContains(dialogs, "LiveRegionMode.Polite") + + val recoveryDialog = bracedBlockFrom(dialogs, "fun ProfileRecoveryDialog(") + assertContains(recoveryDialog, "onDismissRequest = {}") + assertFalse(recoveryDialog.contains("dismissButton")) + assertContains(recoveryDialog, "Res.string.profile_recovery_title") + assertContains(recoveryDialog, "Res.string.profile_recovery_message") + } + + @Test + fun `legacy Home and Just Lift selectors and their four source files are absent`() { + listOf(main, justLift).forEach { screen -> + assertFalse(screen.contains("ProfileSidePanel")) + assertFalse(screen.contains("ProfileSpeedDial")) + assertFalse(screen.contains("showAddProfileDialog")) + assertFalse(screen.contains("AddProfileDialog(")) + } + + listOf( + "ProfileSidePanel.kt", + "ProfileSpeedDial.kt", + "EditProfileDialog.kt", + "DeleteProfileDialog.kt", + ).forEach { fileName -> + assertNull( + readProjectFile( + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/$fileName", + ), + fileName, + ) + } + } + + private fun source(path: String): String = requireNotNull(readProjectFile(path)) { path } + + private fun bracedBlockFrom(source: String, marker: String): String { + val markerIndex = source.indexOf(marker) + require(markerIndex >= 0) { "Missing marker: $marker" } + val openIndex = source.indexOf('{', markerIndex) + require(openIndex >= 0) { "Missing opening brace after: $marker" } + var depth = 0 + for (index in openIndex until source.length) { + when (source[index]) { + '{' -> depth += 1 + '}' -> { + depth -= 1 + if (depth == 0) return source.substring(markerIndex, index + 1) + } + } + } + error("Missing closing brace after: $marker") + } +} diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/AssessmentResourceContractTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/AssessmentResourceContractTest.kt new file mode 100644 index 000000000..3e5a41ca8 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/AssessmentResourceContractTest.kt @@ -0,0 +1,28 @@ +package com.devil.phoenixproject.presentation.screen + +import com.devil.phoenixproject.testutil.readProjectFile +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertNotNull + +class AssessmentResourceContractTest { + @Test + fun `save failure copy exists in every selectable locale`() { + listOf( + "values", + "values-nl", + "values-de", + "values-es", + "values-fr", + ).forEach { directory -> + val path = "src/commonMain/composeResources/$directory/strings.xml" + val source = readProjectFile(path) + assertNotNull(source, "Could not read $path") + assertContains( + charSequence = source, + other = """name="assessment_save_failed"""", + message = path, + ) + } + } +} diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/DominatrixUnlockGateTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/DominatrixUnlockGateTest.kt index cf49f1e7b..e93feb263 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/DominatrixUnlockGateTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/DominatrixUnlockGateTest.kt @@ -15,27 +15,29 @@ import kotlin.test.assertTrue private class DominatrixEasterEggCounter( private val currentTimeMillis: () -> Long, private val onUnlock: () -> Unit, - private val vulgarModeEnabled: () -> Boolean, + private val unlockEligible: () -> Boolean, private val alreadyUnlocked: () -> Boolean, ) { private var tapCount = 0 - private var lastTapTime = 0L + private var firstTapTime: Long? = null fun tap() { // Gate: counter only counts when vulgar mode is on AND not yet unlocked - if (!vulgarModeEnabled() || alreadyUnlocked()) return + if (!unlockEligible() || alreadyUnlocked()) return val now = currentTimeMillis() - if (now - lastTapTime > 2000L) { + val firstTap = firstTapTime + if (firstTap == null || now - firstTap > 2000L) { tapCount = 1 + firstTapTime = now } else { tapCount++ } - lastTapTime = now if (tapCount >= 7) { onUnlock() tapCount = 0 + firstTapTime = null } } @@ -50,7 +52,7 @@ class DominatrixUnlockGateTest { val counter = DominatrixEasterEggCounter( currentTimeMillis = { 0L }, onUnlock = { unlocked = true }, - vulgarModeEnabled = { false }, + unlockEligible = { false }, alreadyUnlocked = { unlocked }, ) @@ -62,12 +64,12 @@ class DominatrixUnlockGateTest { @Test fun `7-tap counter increments when vulgar mode is on`() { - var time = 0L + var time = 1L var unlocked = false val counter = DominatrixEasterEggCounter( currentTimeMillis = { time }, onUnlock = { unlocked = true }, - vulgarModeEnabled = { true }, + unlockEligible = { true }, alreadyUnlocked = { unlocked }, ) @@ -82,12 +84,12 @@ class DominatrixUnlockGateTest { @Test fun `7-tap counter resets after 2-second gap`() { - var time = 0L + var time = 1L var unlocked = false val counter = DominatrixEasterEggCounter( currentTimeMillis = { time }, onUnlock = { unlocked = true }, - vulgarModeEnabled = { true }, + unlockEligible = { true }, alreadyUnlocked = { unlocked }, ) @@ -124,14 +126,65 @@ class DominatrixUnlockGateTest { ) } + @Test + fun `counter requires adult VBT verbal and vulgar prerequisites`() { + var adult = true + var vbt = true + var verbal = true + var vulgar = true + var unlockCount = 0 + val counter = DominatrixEasterEggCounter( + currentTimeMillis = { 0L }, + onUnlock = { unlockCount++ }, + unlockEligible = { adult && vbt && verbal && vulgar }, + alreadyUnlocked = { false }, + ) + + listOf<(Boolean) -> Unit>( + { adult = it }, + { vbt = it }, + { verbal = it }, + { vulgar = it }, + ).forEach { setPrerequisite -> + setPrerequisite(false) + repeat(7) { counter.tap() } + setPrerequisite(true) + } + + assertEquals(0, unlockCount) + repeat(6) { counter.tap() } + assertEquals(0, unlockCount) + counter.tap() + assertEquals(1, unlockCount) + } + + @Test + fun `seven taps spread beyond the two second window do not unlock`() { + var time = 1L + var unlocked = false + val counter = DominatrixEasterEggCounter( + currentTimeMillis = { time }, + onUnlock = { unlocked = true }, + unlockEligible = { true }, + alreadyUnlocked = { unlocked }, + ) + + repeat(7) { + counter.tap() + time += 400L + } + + assertFalse(unlocked) + } + @Test fun `counter is no-op when already unlocked`() { - var time = 0L + var time = 1L var unlockCount = 0 val counter = DominatrixEasterEggCounter( currentTimeMillis = { time }, onUnlock = { unlockCount++ }, - vulgarModeEnabled = { true }, + unlockEligible = { true }, alreadyUnlocked = { true }, // pretend already unlocked ) @@ -142,4 +195,4 @@ class DominatrixUnlockGateTest { assertEquals(0, unlockCount, "Already-unlocked state is permanent") } -} \ No newline at end of file +} diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailOneRepMaxLoadTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailOneRepMaxLoadTest.kt new file mode 100644 index 000000000..7f3d0d093 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailOneRepMaxLoadTest.kt @@ -0,0 +1,165 @@ +package com.devil.phoenixproject.presentation.screen + +import com.devil.phoenixproject.domain.usecase.CurrentOneRepMax +import com.devil.phoenixproject.domain.usecase.CurrentOneRepMaxSource +import com.devil.phoenixproject.testutil.readProjectFile +import kotlin.coroutines.cancellation.CancellationException +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest + +class ExerciseDetailOneRepMaxLoadTest { + private val requestA = ExerciseDetailOneRepMaxRequest("bench", "athlete-a", emptyList()) + private val requestB = ExerciseDetailOneRepMaxRequest("bench", "athlete-b", emptyList()) + private val resultA = CurrentOneRepMax(50f, CurrentOneRepMaxSource.SESSION, 10L) + private val resultB = CurrentOneRepMax(60f, CurrentOneRepMaxSource.ASSESSMENT, 20L) + + @Test + fun `late completion from A cannot overwrite Ready B`() = runTest { + val gate = ExerciseDetailOneRepMaxLoadGate() + val states = mutableListOf() + val aStarted = CompletableDeferred() + val releaseA = CompletableDeferred() + val loadA = async { + loadExerciseDetailOneRepMax( + request = requestA, + gate = gate, + resolve = { _, _ -> + aStarted.complete(Unit) + releaseA.await() + resultA + }, + publish = states::add, + ) + } + runCurrent() + aStarted.await() + + loadExerciseDetailOneRepMax( + request = requestB, + gate = gate, + resolve = { _, _ -> resultB }, + publish = states::add, + ) + releaseA.complete(Unit) + loadA.await() + + assertEquals( + listOf( + ExerciseDetailOneRepMaxState.Loading, + ExerciseDetailOneRepMaxState.Loading, + ExerciseDetailOneRepMaxState.Ready(resultB), + ), + states, + ) + } + + @Test + fun `late error from A cannot replace Ready B with Failed`() = runTest { + val gate = ExerciseDetailOneRepMaxLoadGate() + val states = mutableListOf() + val aStarted = CompletableDeferred() + val releaseA = CompletableDeferred() + val loadA = async { + loadExerciseDetailOneRepMax( + request = requestA, + gate = gate, + resolve = { _, _ -> + aStarted.complete(Unit) + releaseA.await() + error("late A failure") + }, + publish = states::add, + ) + } + runCurrent() + aStarted.await() + loadExerciseDetailOneRepMax( + request = requestB, + gate = gate, + resolve = { _, _ -> resultB }, + publish = states::add, + ) + releaseA.complete(Unit) + loadA.await() + + assertEquals(ExerciseDetailOneRepMaxState.Ready(resultB), states.last()) + assertFalse(states.contains(ExerciseDetailOneRepMaxState.Failed)) + } + + @Test + fun `cancellation escapes and is never rendered as failure`() = runTest { + val states = mutableListOf() + val started = CompletableDeferred() + val child = async { + loadExerciseDetailOneRepMax( + request = requestA, + gate = ExerciseDetailOneRepMaxLoadGate(), + resolve = { _, _ -> + started.complete(Unit) + awaitCancellation() + }, + publish = states::add, + ) + } + runCurrent() + started.await() + + val cause = CancellationException("profile switched") + child.cancel(cause) + val thrown = assertFailsWith { child.await() } + + assertEquals(cause.message, thrown.message) + assertEquals( + listOf(ExerciseDetailOneRepMaxState.Loading), + states, + ) + } + + @Test + fun `ordinary current-request error fails only the one rep max branch`() = runTest { + val states = mutableListOf() + + loadExerciseDetailOneRepMax( + request = requestA, + gate = ExerciseDetailOneRepMaxLoadGate(), + resolve = { _, _ -> error("resolver unavailable") }, + publish = states::add, + ) + + assertEquals( + listOf( + ExerciseDetailOneRepMaxState.Loading, + ExerciseDetailOneRepMaxState.Failed, + ), + states, + ) + } + + @Test + fun `screen has one resolver path and no legacy profile or velocity read`() { + val source = readProjectFile( + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailScreen.kt", + ) + assertNotNull(source) + assertContains(source, "assessmentProfileId") + assertContains(source, "loadExerciseDetailOneRepMax(") + assertContains(source, "estimatedOneRepMaxPerCableOrNull()") + assertContains(source, "catch (cancellation: CancellationException)") + assertContains(source, "catch (_: Exception)") + assertFalse(source.contains("catch (_: Throwable)")) + assertContains(source, "VolumeChartCard(") + assertContains(source, "items(exerciseSessions") + assertFalse(source.contains("viewModel.activeProfileId")) + assertFalse(source.contains("velocityOneRepMaxRepository")) + assertFalse(source.contains("VelocityOneRepMaxEntity")) + } +} diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ProfileResourceContractTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ProfileResourceContractTest.kt new file mode 100644 index 000000000..d3e63d29a --- /dev/null +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ProfileResourceContractTest.kt @@ -0,0 +1,223 @@ +package com.devil.phoenixproject.presentation.screen + +import com.devil.phoenixproject.testutil.readProjectFile +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class ProfileResourceContractTest { + private val selectableLocales = linkedMapOf( + "en" to "values", + "nl" to "values-nl", + "de" to "values-de", + "es" to "values-es", + "fr" to "values-fr", + ) + + private val files = selectableLocales.mapValues { (_, directory) -> + "src/commonMain/composeResources/$directory/strings.xml" + } + + private val newKeys = listOf( + "nav_profile", + "cd_profile", + "cd_open_profile_switcher", + "profiles_title", + "switch_profile", + "profile_exercise_insights", + "profile_choose_exercise", + "profile_no_exercise_history", + "profile_current_one_rep_max", + "profile_one_rep_max_source_velocity", + "profile_one_rep_max_source_assessment", + "profile_one_rep_max_source_session", + "profile_one_rep_max_source_none", + "profile_pr_highlights", + "profile_pr_max_weight", + "profile_pr_estimated_one_rep_max", + "profile_pr_max_volume", + "profile_recent_history", + "profile_view_full_history", + "profile_preferences_title", + "profile_measurements", + "profile_workout_behavior", + "profile_led", + "profile_vbt", + "profile_safety", + "profile_vbt_enabled", + "profile_switching", + "profile_missing_exercise", + "profile_insights_load_failed", + "profile_update_failed", + "profile_switch_failed", + "profile_create_failed", + "profile_recovery_title", + "profile_recovery_message", + "profile_recovery_retry_failed", + "profile_delete_reassign_message", + "settings_video_behavior", + "settings_show_exercise_videos", + "settings_show_exercise_videos_description", + "profile_weight_increment", + "profile_body_weight", + "profile_set_summary", + "profile_autostart_countdown", + "profile_auto_start_routine", + "profile_audio_rep_counter", + "profile_countdown_beeps", + "profile_rep_completion_sound", + "profile_motion_start", + "profile_gamification", + "profile_default_scaling_basis", + "profile_stop_at_top", + "profile_stall_detection", + "profile_velocity_loss_threshold", + "profile_auto_end_velocity_loss", + "profile_vbt_history_note", + ) + + private val reusedKeys = listOf( + "settings_weight_unit", + "equipment_rack_title", + "equipment_rack_manage", + "cd_led_scheme", + "color_blue", + "color_green", + "color_amber", + "color_red", + "color_purple", + "color_pink", + "color_cyan", + "color_orange", + "cd_select_profile_color", + ) + + private val expectedLedSchemeLabels = mapOf( + "en" to "LED color scheme", + "nl" to "LED-kleurenschema", + "de" to "LED-Farbschema", + "es" to "Esquema de color LED", + "fr" to "Palette de couleurs LED", + ) + + private val expectedPlaceholders = mapOf( + "profile_delete_reassign_message" to listOf("%1${'$'}s"), + "cd_select_profile_color" to listOf("%1${'$'}s"), + ) + + private val expectedColorLabels = mapOf( + "en" to listOf("Blue", "Green", "Amber", "Red", "Purple", "Pink", "Cyan", "Orange"), + "nl" to listOf("Blauw", "Groen", "Amber", "Rood", "Paars", "Roze", "Cyaan", "Oranje"), + "de" to listOf("Blau", "Grün", "Bernstein", "Rot", "Lila", "Rosa", "Cyan", "Orange"), + "es" to listOf("Azul", "Verde", "Ámbar", "Rojo", "Morado", "Rosa", "Cian", "Naranja"), + "fr" to listOf("Bleu", "Vert", "Ambre", "Rouge", "Violet", "Rose", "Cyan", "Orange"), + ) + + private val placeholderPattern = Regex("""%\d+\${'$'}[A-Za-z]""") + private val invalidAmpersandPattern = + Regex("""&(?!amp;|lt;|gt;|quot;|apos;|#\d+;|#x[0-9A-Fa-f]+;)""") + + @Test + fun contractInventoryIsUniqueAndTracksExactlyFiveSelectableLocales() { + assertEquals(55, newKeys.size) + assertEquals(newKeys.size, newKeys.toSet().size) + assertTrue(newKeys.intersect(reusedKeys.toSet()).isEmpty()) + + val settings = assertNotNull( + readProjectFile( + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/SettingsTab.kt", + ), + ) + selectableLocales.keys.forEach { languageCode -> + assertContains(settings, "\"$languageCode\" to stringResource") + } + assertFalse(settings.contains("\"it\" to stringResource")) + } + + @Test + fun selectableLocalesContainOneWellFormedDeclarationPerContractKey() { + files.forEach { (languageCode, path) -> + val source = assertNotNull(readProjectFile(path), "Missing $path") + + val names = Regex(""" 1 } + assertTrue(duplicateNames.isEmpty(), "$path duplicates $duplicateNames") + assertFalse( + invalidAmpersandPattern.containsMatchIn(source), + "$path contains an unescaped XML ampersand", + ) + + (newKeys + reusedKeys).forEach { key -> + val declarationCount = Regex(""")""") + .findAll(source) + .count() + assertEquals(1, declarationCount, "$path declaration count for $key") + } + assertEquals( + expectedLedSchemeLabels.getValue(languageCode), + resourceValue(source, "cd_led_scheme", path), + "$path translated LED scheme label", + ) + assertEquals( + expectedColorLabels.getValue(languageCode), + listOf( + "color_blue", + "color_green", + "color_amber", + "color_red", + "color_purple", + "color_pink", + "color_cyan", + "color_orange", + ).map { resourceValue(source, it, path) }, + "$path translated profile color labels", + ) + + (newKeys + reusedKeys).forEach { key -> + val value = resourceValue(source, key, path) + val placeholders = placeholderPattern.findAll(value) + .map { it.value } + .toList() + assertEquals( + expectedPlaceholders[key].orEmpty(), + placeholders, + "$path placeholder contract for $key", + ) + } + } + } + + @Test + fun sourceReaderActualsSupportSharedRelativeContractPaths() { + val androidReader = assertNotNull( + readProjectFile( + "src/androidHostTest/kotlin/com/devil/phoenixproject/testutil/SourceFileReader.android.kt", + ), + ) + val iosReader = assertNotNull( + readProjectFile( + "src/iosTest/kotlin/com/devil/phoenixproject/testutil/SourceFileReader.ios.kt", + ), + ) + + assertContains(androidReader, """File(dir, "shared/${'$'}relativePath")""") + assertContains(iosReader, """candidates.add("${'$'}dir/shared/${'$'}relativePath")""") + } + + private fun resourceValue(source: String, key: String, path: String): String = + assertNotNull( + Regex( + """]*>(.*?)""", + RegexOption.DOT_MATCHES_ALL, + ).find(source)?.groupValues?.get(1), + "$path is missing the value for $key", + ) +} diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ProfileScreenContractTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ProfileScreenContractTest.kt new file mode 100644 index 000000000..0eb73bb4e --- /dev/null +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ProfileScreenContractTest.kt @@ -0,0 +1,783 @@ +package com.devil.phoenixproject.presentation.screen + +import com.devil.phoenixproject.domain.model.WorkoutSession +import com.devil.phoenixproject.presentation.components.buildProfileRecentHistory +import com.devil.phoenixproject.presentation.viewmodel.ProfileIdentityMutationKind +import com.devil.phoenixproject.testutil.readProjectFile +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ProfileScreenContractTest { + private val screenPath = + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ProfileScreen.kt" + private val insightsPath = + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileExerciseInsights.kt" + private val preferenceComponentsPath = + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfilePreferenceComponents.kt" + private val safetyDialogsPath = + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileSafetyDialogs.kt" + private val settingsPath = + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/SettingsTab.kt" + private val navGraphPath = + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/navigation/NavGraph.kt" + private val defaultStringsPath = + "src/commonMain/composeResources/values/strings.xml" + + @Test + fun screenTagAndNonReadyLoaderUseTheRealLoadingIndicatorApi() { + val screen = source(screenPath) + val tags = source( + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/util/TestTags.kt", + ) + + assertContains(tags, "const val SCREEN_PROFILE") + assertContains(screen, "TestTags.SCREEN_PROFILE") + assertContains(screen, "LoadingIndicator(LoadingIndicatorSize.Large)") + assertContains(screen, "Res.string.profile_switching") + assertFalse(screen.contains("message =")) + } + + @Test + fun profileScaffoldLeavesInsetsToTheOuterAppAndKeepsTwelveDpCardSpacing() { + val screen = source(screenPath) + + assertContains(screen, "contentWindowInsets = WindowInsets(0, 0, 0, 0)") + assertContains(screen, "padding.calculateTopPadding() + 12.dp") + } + + @Test + fun selectionFailurePrecedesMissingAndEmptyWhilePickerRemainsAvailable() { + val insights = source(insightsPath) + val selector = insights.indexOf("onChooseExercise") + val failure = insights.indexOf("selectionFailure != null") + val missing = insights.indexOf("missingExerciseId != null") + val empty = insights.indexOf("Res.string.profile_no_exercise_history") + + assertTrue(selector >= 0) + assertTrue(failure in 0 until missing, "selection failure must precede missing") + assertTrue(missing in 0 until empty, "missing must precede ordinary empty") + assertContains(insights, "Res.string.profile_insights_load_failed") + } + + @Test + fun pickerAndInsightsUseTheCompleteResourceAndSourceInventory() { + val screen = source(screenPath) + val insights = source(insightsPath) + + assertContains(screen, "ExercisePickerDialog(") + assertContains(screen, "enableCustomExercises = false") + assertFalse(insights.contains("oneRepMaxKg")) + listOf( + "profile_exercise_insights", + "profile_choose_exercise", + "profile_no_exercise_history", + "profile_current_one_rep_max", + "profile_one_rep_max_source_velocity", + "profile_one_rep_max_source_assessment", + "profile_one_rep_max_source_session", + "profile_one_rep_max_source_none", + "profile_pr_highlights", + "profile_pr_max_weight", + "profile_pr_estimated_one_rep_max", + "profile_pr_max_volume", + "profile_recent_history", + "profile_view_full_history", + "profile_missing_exercise", + "profile_insights_load_failed", + ).forEach { key -> assertContains(insights, "Res.string.$key", message = key) } + } + + @Test + fun recentHistoryIsDeterministicBoundedChronologicalAndFinite() { + val result = buildProfileRecentHistory( + sessions = listOf( + session("old", 1, 10f), + session("outside", 2, 20f), + session("zero", 3, 0f), + session("middle", 4, 40f), + session("nan", 5, Float.NaN), + session("tie-a", 6, 50f), + session("tie-b", 6, 60f), + ), + labelForTimestamp = Long::toString, + ) + + assertEquals( + listOf("tie-b", "tie-a", "nan", "middle", "zero"), + result.sessionsNewestFirst.map(WorkoutSession::id), + ) + assertEquals(listOf("4", "6", "6"), result.chartPointsOldestFirst.map { it.label }) + assertEquals(listOf(40f, 50f, 60f), result.chartPointsOldestFirst.map { it.volume }) + assertTrue(result.chartPointsOldestFirst.all { it.volume.isFinite() && it.volume > 0f }) + } + + @Test + fun prVolumeIsExplicitlyPerCableAndNeverUsesCableMultiplication() { + val insights = source(insightsPath) + val start = insights.indexOf("fun ProfilePrHighlightsCard(") + val end = insights.indexOf("fun ProfileRecentHistoryCard(") + assertTrue(start >= 0 && end > start) + val prCard = insights.substring(start, end) + + assertContains(prCard, "maxVolumeKg") + assertContains(prCard, "formatPerCableWeight") + assertContains(prCard, "per-cable kg x reps") + assertFalse(prCard.contains("cableCount")) + assertFalse(prCard.contains("effectiveTotalVolumeKg")) + } + + @Test + fun overlaysEventsActionsAndTagsAreProfileScopedAndAccessible() { + val screen = source(screenPath) + val dialogs = source( + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileDialogs.kt", + ) + + listOf("pickerProfileId", "editTargetProfileId", "deleteTargetProfileId").forEach { + assertContains(screen, it) + } + assertContains(screen, "event.profileId") + assertContains(screen, "event.kind") + assertContains(screen, "ProfileUiEvent.ProfileRecoveryRequired") + assertContains(screen, "heightIn(min = 48.dp)") + assertContains(screen, "Role.Button") + assertContains(screen, "contentDescription") + assertEquals(1, Regex("TestTags\\.ACTION_EDIT_PROFILE").findAll(screen).count()) + assertEquals(1, Regex("TestTags\\.ACTION_DELETE_PROFILE").findAll(screen).count()) + assertFalse(dialogs.contains("TestTags.ACTION_EDIT_PROFILE")) + assertFalse(dialogs.contains("TestTags.ACTION_DELETE_PROFILE")) + } + + @Test + fun deletionOverlayOwnershipSurvivesSwitchingRollbackAndScopedFailure() { + val initial = ProfileIdentityOverlayOwnership( + deleteTargetProfileId = "a", + pendingIdentityProfileId = "a", + ) + + val switching = retainProfileIdentityOverlayOwnership( + ownership = initial, + readyProfileId = null, + ) + assertEquals(initial, switching) + + val rolledBack = retainProfileIdentityOverlayOwnership( + ownership = switching, + readyProfileId = "a", + ) + assertEquals(initial, rolledBack) + + val failure = applyProfileIdentityFailure( + ownership = rolledBack, + profileId = "a", + kind = ProfileIdentityMutationKind.DELETE, + ) + assertTrue(failure.showError) + assertEquals( + ProfileIdentityOverlayOwnership(deleteTargetProfileId = "a"), + failure.ownership, + ) + + assertEquals( + ProfileIdentityOverlayOwnership(), + retainProfileIdentityOverlayOwnership( + ownership = rolledBack, + readyProfileId = "default", + ), + ) + } + + @Test + fun preferenceResourceInventoryOccursOnceInEveryLocaleAndVisibleCopyIsNotHardcoded() { + val english = linkedMapOf( + "profile_master_beeps" to "Workout beeps", + "profile_body_weight_unset" to "Not set", + "profile_body_weight_invalid" to "Enter a body weight from 20 to 300 kg", + "profile_body_weight_imported" to "Matches an imported health measurement", + "profile_led_scheme_blue" to "Blue", + "profile_led_scheme_green" to "Green", + "profile_led_scheme_teal" to "Teal", + "profile_led_scheme_yellow" to "Yellow", + "profile_led_scheme_pink" to "Pink", + "profile_led_scheme_red" to "Red", + "profile_led_scheme_purple" to "Purple", + "profile_led_scheme_none" to "None", + "cd_select_led_scheme" to "Select LED scheme: %1\$s", + "profile_led_selected_scheme" to "Selected: %1\$s", + "profile_disco_mode" to "Disco Mode", + "profile_disco_requires_connection" to "Connect to your trainer to use Disco Mode", + "profile_disco_unlocked_title" to "Disco Mode unlocked", + "profile_disco_unlocked_body" to + "Turn on Disco Mode in LED preferences to make your trainer party.", + "profile_disco_unlocked_action" to "Let's party", + "profile_adult_enable_partial_failure" to + "Age confirmation was saved, but Vulgar Mode could not be enabled. Try again.", + ) + val placeholderPattern = Regex("""%\d+\${'$'}[A-Za-z]""") + fun placeholderMultiset(value: String): Map = placeholderPattern + .findAll(value) + .map { it.value } + .groupingBy { it } + .eachCount() + + val localeValues = linkedMapOf>() + listOf("values", "values-de", "values-es", "values-fr", "values-nl").forEach { locale -> + val xml = source("src/commonMain/composeResources/$locale/strings.xml") + english.keys.forEach { key -> + assertEquals( + 1, + Regex("""name\s*=\s*\"${Regex.escape(key)}\"""").findAll(xml).count(), + "$locale:$key", + ) + } + localeValues[locale] = english.keys.associateWith { key -> resourceValue(xml, key) } + } + assertEquals(english, localeValues.getValue("values")) + localeValues.forEach { (locale, localized) -> + english.forEach { (key, value) -> + assertEquals( + placeholderMultiset(value), + placeholderMultiset(localized.getValue(key)), + "$locale:$key placeholder multiset", + ) + } + } + + val proseThatMustBeTranslated = listOf( + "profile_master_beeps", + "profile_body_weight_unset", + "profile_body_weight_invalid", + "profile_body_weight_imported", + "cd_select_led_scheme", + "profile_led_selected_scheme", + "profile_disco_requires_connection", + "profile_disco_unlocked_title", + "profile_disco_unlocked_body", + "profile_disco_unlocked_action", + "profile_adult_enable_partial_failure", + ) + listOf("values-de", "values-es", "values-fr", "values-nl").forEach { locale -> + val localized = localeValues.getValue(locale) + proseThatMustBeTranslated.forEach { key -> + assertFalse( + localized.getValue(key).equals(english.getValue(key), ignoreCase = true), + "$locale must translate $key idiomatically", + ) + } + assertTrue( + english.keys.count { key -> localized.getValue(key) != english.getValue(key) } >= 17, + "$locale contains too many copied English preference strings", + ) + } + + val visibleSource = + source(screenPath) + source(preferenceComponentsPath) + source(safetyDialogsPath) + english.forEach { (key, value) -> + assertContains(visibleSource, "Res.string.$key", message = key) + assertFalse(visibleSource.contains("\"$value\""), "hardcoded visible copy: $value") + } + assertFalse(visibleSource.contains("scheme.name")) + assertFalse(visibleSource.contains("ColorScheme.name")) + } + + @Test + fun profileScreenKeepsOneEventCollectorAndFiltersCurrentProfileAndTrackedToken() { + val screen = source(screenPath) + assertEquals(1, Regex("viewModel\\.events\\.collect").findAll(screen).count()) + val collector = bracedBlock(screen, "viewModel.events.collect") + val success = bracedBlock( + collector, + "is ProfileUiEvent.PreferenceMutationSucceeded", + ) + val failure = bracedBlock( + collector, + "is ProfileUiEvent.PreferenceUpdateFailed", + ) + assertCurrentProfileAndTrackedTokenBeforeKind(success, "success") + assertCurrentProfileAndTrackedTokenBeforeKind(failure, "failure") + } + + @Test + fun profileRouteUsesTransientMainActionsAndPassesAllNavigationCallbacks() { + val graph = source(navGraphPath) + val route = graph.substring( + graph.indexOf("route = NavigationRoutes.Profile.route").also { assertTrue(it >= 0) }, + graph.indexOf("// Exercise Detail screen").also { assertTrue(it >= 0) }, + ) + + listOf( + "onOpenProfileSwitcher = onOpenProfileSwitcher", + "onNavigateToExerciseDetail", + "onNavigateToEquipmentRack", + "NavigationRoutes.EquipmentRack.route", + "onNavigateToBadges", + "NavigationRoutes.Badges.route", + "onProfileRecoveryRequired = onProfileRecoveryRequired", + "viewModel::toggleDiscoMode", + "viewModel::emitDiscoSound", + "viewModel::emitDominatrixUnlockSound", + ).forEach { contract -> assertContains(route, contract, message = contract) } + val persistedMainViewModelDenylist = listOf( + "setBodyWeightKg", + "setWeightUnit", + "setWeightIncrement", + "setEnableVideoPlayback", + "setStopAtTop", + "setStallDetectionEnabled", + "setAudioRepCountEnabled", + "setRepCountTiming", + "setColorScheme", + "setSummaryCountdownSeconds", + "setAutoStartCountdownSeconds", + "setAutoStartRoutine", + "setGamificationEnabled", + "setCountdownBeepsEnabled", + "setRepSoundEnabled", + "setMotionStartEnabled", + "setWeightSuggestionsEnabled", + "setBleCompatibilityMode", + "setAutoBackupEnabled", + "setBackupDestination", + "setLanguage", + "setVelocityLossThreshold", + "setAutoEndOnVelocityLoss", + "setDefaultScalingBasis", + "setDefaultRoutineExerciseUsePercentOfPR", + "setDefaultRoutineExerciseWeightPercentOfPR", + "setVerbalEncouragementEnabled", + "setVulgarModeEnabled", + "setVulgarTier", + "setDominatrixModeUnlocked", + "setDominatrixModeActive", + "setVoiceStopEnabled", + "setSafeWord", + "setSafeWordCalibrated", + "setAdultsOnlyConfirmed", + "confirmAdultsAndEnableVulgar", + "setAdultsOnlyPrompted", + "unlockDiscoMode", + "saveRackItem", + "deleteRackItem", + "saveRackBehaviorOverridesForExercise", + "updateActiveRackSelection", + "updateActiveRackBehaviorOverrides", + "clearActiveRackSelection", + ) + persistedMainViewModelDenylist.forEach { forbidden -> + assertFalse(route.contains("viewModel.$forbidden"), forbidden) + assertFalse(route.contains("viewModel::$forbidden"), forbidden) + assertFalse(route.contains("$forbidden("), forbidden) + } + assertFalse(Regex("""viewModel(?:::|\.)set[A-Z]\w*""").containsMatchIn(route)) + assertFalse(Regex("""viewModel(?:::|\.)unlock[A-Z]\w*""").containsMatchIn(route)) + assertFalse(Regex("""viewModel(?:::|\.)confirmAdults\w*""").containsMatchIn(route)) + } + + @Test + fun adultAndUnlockDialogsReactOnlyToMatchingPostCommitOutcomes() { + val screen = source(screenPath) + listOf( + "ProfileUiEvent.PreferenceMutationSucceeded", + "ProfileUiEvent.PreferenceUpdateFailed", + "ProfilePreferenceMutationKind.ADULT_ENABLE", + "ProfilePreferenceMutationKind.ADULT_DECLINE", + "ProfilePreferenceMutationKind.DISCO_UNLOCK", + "ProfilePreferenceMutationKind.DOMINATRIX_UNLOCK", + "AdultsOnlyConfirmDialog(", + "DiscoModeUnlockDialog(", + "DominatrixUnlockDialog(", + "onPlayDiscoUnlockSound", + "onPlayDominatrixUnlockSound", + ).forEach { contract -> assertContains(screen, contract, message = contract) } + val collector = bracedBlock(screen, "viewModel.events.collect") + val success = bracedBlock( + collector, + "is ProfileUiEvent.PreferenceMutationSucceeded", + ) + val failure = bracedBlock( + collector, + "is ProfileUiEvent.PreferenceUpdateFailed", + ) + val adultBranch = mutationKindBranch(success, "ADULT_ENABLE") + val adultFailureBranch = mutationKindBranch(failure, "ADULT_ENABLE") + val ordinaryFailureBranch = mutationKindBranch(failure, "UPDATE") + val discoBranch = mutationKindBranch(success, "DISCO_UNLOCK") + val dominatrixBranch = mutationKindBranch(success, "DOMINATRIX_UNLOCK") + + assertTrue( + adultBranch.contains("showAdultsOnlyDialog = false") || + Regex("""adult\w*(?:Target|Dialog)\w*\s*=\s*null""").containsMatchIn(adultBranch), + "adult dialog may close only in the matching committed-success branch", + ) + assertContains(discoBranch, "onPlayDiscoUnlockSound()") + assertContains(discoBranch, "showDiscoUnlockDialog = true") + assertContains(dominatrixBranch, "onPlayDominatrixUnlockSound()") + assertContains(dominatrixBranch, "showDominatrixUnlockDialog = true") + assertTrue( + Regex("""adult\w*[Ee]rror\w*\s*=""").containsMatchIn(adultFailureBranch), + "ADULT_ENABLE failure must update inline adult modal error state", + ) + assertContains(adultFailureBranch, "event.committedSections") + assertContains(adultFailureBranch, "ProfilePreferenceSection.LOCAL_SAFETY") + assertFalse(adultFailureBranch.contains("snackbarHostState.showSnackbar")) + assertFalse(adultFailureBranch.contains("showAdultsOnlyDialog = false")) + assertFalse(adultFailureBranch.contains("onPlayDiscoUnlockSound()")) + assertFalse(adultFailureBranch.contains("onPlayDominatrixUnlockSound()")) + assertContains(ordinaryFailureBranch, "snackbarHostState.showSnackbar") + assertEquals(1, Regex("onPlayDiscoUnlockSound\\(\\)").findAll(screen).count()) + assertEquals(1, Regex("onPlayDominatrixUnlockSound\\(\\)").findAll(screen).count()) + assertEquals(1, Regex("showDiscoUnlockDialog\\s*=\\s*true").findAll(screen).count()) + assertEquals(1, Regex("showDominatrixUnlockDialog\\s*=\\s*true").findAll(screen).count()) + + val preferenceCall = parenthesizedCall(screen, "ProfilePreferenceSections(") + assertFalse(preferenceCall.contains("onPlayDiscoUnlockSound()")) + assertFalse(preferenceCall.contains("onPlayDominatrixUnlockSound()")) + assertFalse(preferenceCall.contains("showDiscoUnlockDialog = true")) + assertFalse(preferenceCall.contains("showDominatrixUnlockDialog = true")) + } + + @Test + fun achievementsPrecedesPreferencesAndIsUnconditional() { + val screen = source(screenPath) + val insights = screen.indexOf("item(key = \"exercise-insights\")") + val achievements = screen.indexOf("item(key = \"achievements\")") + val heading = screen.indexOf("item(key = \"preferences-heading\")") + val preferences = screen.indexOf("item(key = \"profile-preferences\")") + + assertTrue(insights in 0 until achievements) + assertTrue(achievements in 0 until heading) + assertTrue(heading in 0 until preferences) + assertContains(screen.substring(achievements, heading), "TestTags.ACTION_BADGES") + assertFalse(screen.substring(achievements, heading).contains("gamificationEnabled")) + } + + @Test + fun continuousSlidersDraftLocallyAndCommitOnlyWhenFinished() { + val preferences = source(preferenceComponentsPath) + assertEquals(1, Regex("\\bSlider\\(").findAll(preferences).count()) + assertEquals(1, Regex("onValueChangeFinished\\s*=").findAll(preferences).count()) + assertEquals(1, Regex("onValueChange\\s*=").findAll(preferences).count()) + assertSliderContract( + source = preferences, + authoritativeField = "velocityLossThresholdPercent", + section = "VBT", + finalCallback = "onVbtChange", + ) + assertFalse( + Regex("""onValueChange\s*=\s*\{[^}]*on(?:Workout|Vbt)Change""", RegexOption.DOT_MATCHES_ALL) + .containsMatchIn(preferences), + "continuous onValueChange must update only a local draft", + ) + } + + @Test + fun exerciseLevelDefaultsAndRoutinePrSeedingAreAbsentFromProfile() { + val preferences = source(preferenceComponentsPath) + val workoutCard = bracedBlock(preferences, "fun WorkoutPreferenceCard(") + val vbtCard = bracedBlock(preferences, "fun VbtPreferenceCard(") + + listOf( + "profile_default_rest", + "profile_rep_count_timing", + "profile_stop_at_top", + "profile_stall_detection", + "defaultRestSeconds", + "repCountTiming", + "stopAtTop", + "stallDetectionEnabled", + "profile_routine_starting_weights", + "profile_routine_starting_weights_description", + "defaultRoutineExerciseUsePercentOfPR", + "defaultRoutineExerciseWeightPercentOfPR", + "50f..120f", + ).forEach { removed -> + assertFalse(workoutCard.contains(removed), "Profile retained $removed") + } + assertFalse(vbtCard.contains("stallDetectionEnabled")) + } + + @Test + fun vulgarModeSolelyOwnsAgePromptAndDominatrixUnlockLivesOnTheVbtTitle() { + val preferences = source(preferenceComponentsPath) + val safetyCard = bracedBlock(preferences, "fun SafetyPreferenceCard(") + val vbtCard = bracedBlock(preferences, "fun VbtPreferenceCard(") + + assertFalse(safetyCard.contains("onRequestAdultsOnlyConfirmation")) + assertFalse(safetyCard.contains("settings_adults_only_title")) + assertEquals(1, Regex("onRequestAdultsOnlyConfirmation\\(\\)").findAll(vbtCard).count()) + assertContains(vbtCard, "titleModifier = Modifier.clickable(") + assertContains(vbtCard, "vbt.enabled &&") + assertContains(vbtCard, "onUnlockDominatrixMode()") + assertTrue( + Regex( + """if\s*\(vbt\.dominatrixModeUnlocked\)\s*\{\s*PreferenceSwitchRow\(""", + ).containsMatchIn(vbtCard), + "Dominatrix row must not exist in the semantics tree before unlock", + ) + assertFalse(vbtCard.contains("settings_dominatrix_unlock_hint")) + assertContains(vbtCard, "vbtAfterEnabledSelection") + assertContains(vbtCard, "vbtAfterVerbalEncouragementSelection") + assertContains(vbtCard, "vbtAfterVulgarModeSelection") + } + + @Test + fun dominatrixUnlockDialogUsesTheFeatureName() { + val strings = source(defaultStringsPath) + + assertContains( + strings, + "" + + "Dominatrix Mode unlocked", + ) + } + + @Test + fun ledOptionsUseStableLocalizedIndicesAndRadioSemantics() { + val preferences = source(preferenceComponentsPath) + val ledCard = bracedBlock(preferences, "fun LedPreferenceCard(") + val labels = listOf( + "profile_led_scheme_blue", + "profile_led_scheme_green", + "profile_led_scheme_teal", + "profile_led_scheme_yellow", + "profile_led_scheme_pink", + "profile_led_scheme_red", + "profile_led_scheme_purple", + "profile_led_scheme_none", + ) + labels.forEachIndexed { index, label -> + assertTrue( + Regex( + """\b$index\s+to\s+Res\.string\.$label|index\s*=\s*$index[\s\S]{0,100}Res\.string\.$label""", + ).containsMatchIn(preferences), + "LED index $index must map explicitly to $label", + ) + } + listOf( + "normalizedLedSchemeIndex(", + "LazyRow(", + "selectableGroup", + "Role.RadioButton", + "selected =", + "Modifier.size(48.dp)", + "Brush.linearGradient", + "Icons.Default.Check", + "drawLine(", + "contentDescription", + "Res.string.cd_select_led_scheme", + "Res.string.profile_led_selected_scheme", + ) + .forEach { contract -> assertContains(preferences, contract, message = contract) } + val ledWrites = Regex("onLedChange\\s*\\(").findAll(preferences).toList() + assertEquals(1, ledWrites.size, "normalization must not write a fallback LED section") + val ledWriteIndex = ledWrites.single().range.first + val clickWindow = preferences.substring((ledWriteIndex - 400).coerceAtLeast(0), ledWriteIndex) + assertContains(clickWindow, "onClick") + assertFalse( + Regex( + """(?:LaunchedEffect|SideEffect)\s*\([^)]*\)\s*\{[^}]*onLedChange""", + RegexOption.DOT_MATCHES_ALL, + ).containsMatchIn(preferences), + "display normalization must never auto-write index zero", + ) + assertFalse(preferences.contains("scheme.name")) + assertFalse(preferences.contains("ColorScheme.name")) + assertTrue( + Regex( + """selectable\s*\([\s\S]{0,220}enabled\s*=\s*enabled""", + ).containsMatchIn(ledCard), + "busy LED writes must disable each radio swatch", + ) + listOf("2000L", "tapCount >= 7", "onUnlockDiscoMode()").forEach { contract -> + assertContains(ledCard, contract, message = contract) + } + } + + @Test + fun settingsRelinquishesSharedDialogsAndMicrophoneDisposalIsRetained() { + val settings = source(settingsPath) + val dialogs = source(safetyDialogsPath) + listOf( + "SafeWordCalibrationDialog(", + "AdultsOnlyConfirmDialog(", + "DominatrixUnlockDialog(", + "DiscoModeUnlockDialog(", + ).forEach { call -> + val name = call.removeSuffix("(") + assertFalse(settings.contains(call), "Settings retained $call") + assertFalse( + Regex("(?m)^\\s*import\\s+[^\\n]*\\.$name\\s*$").containsMatchIn(settings), + "Settings retained the $name import", + ) + assertEquals(1, Regex("fun\\s+$name\\(").findAll(dialogs).count()) + assertFalse(settings.contains("private fun $name")) + } + listOf( + "DisposableEffect(safeWord)", + "startListening()", + "onDispose", + "stopListening()", + "listener = null", + ).forEach { contract -> assertContains(dialogs, contract, message = contract) } + + val adultDialog = bracedBlock(dialogs, "fun AdultsOnlyConfirmDialog(") + assertContains(adultDialog, "errorMessage?.let") + assertTrue( + Regex("""errorMessage\?\.let\s*\{[\s\S]*?Text\s*\(""") + .containsMatchIn(adultDialog), + "adult error must render inline", + ) + assertTrue( + Regex("""enabled\s*=\s*!isSubmitting""").findAll(adultDialog).count() >= 2, + "confirm and decline must both disable while submitting", + ) + listOf("onConfirm", "onDecline").forEach { callback -> + val click = "onClick\\s*=\\s*(?:$callback|\\{\\s*$callback\\(\\)\\s*\\})" + assertTrue( + Regex( + "(?:$click[\\s\\S]{0,180}enabled\\s*=\\s*!isSubmitting|" + + "enabled\\s*=\\s*!isSubmitting[\\s\\S]{0,180}$click)", + ).containsMatchIn(adultDialog), + "$callback must be attached to a submitting-disabled action", + ) + } + assertContains(adultDialog, "dismissOnBackPress = !isSubmitting") + assertContains(adultDialog, "dismissOnClickOutside = !isSubmitting") + assertTrue( + Regex( + """onDismissRequest\s*=\s*\{[\s\S]{0,160}!isSubmitting[\s\S]{0,160}onDismiss\s*\(""", + ).containsMatchIn(adultDialog), + "scrim, back, and onDismiss must be ignored while submitting", + ) + } + + private fun session(id: String, timestamp: Long, totalVolumeKg: Float) = WorkoutSession( + id = id, + timestamp = timestamp, + totalVolumeKg = totalVolumeKg, + totalReps = 5, + ) + + private fun source(path: String): String = requireNotNull(readProjectFile(path)) { path } + + private fun resourceValue(xml: String, key: String): String = requireNotNull( + Regex( + """]*\bname\s*=\s*\"${Regex.escape(key)}\"[^>]*>(.*?)""", + RegexOption.DOT_MATCHES_ALL, + ).find(xml), + ) { key }.groupValues[1] + .trim() + .replace("'", "'") + .replace("\\'", "'") + + private fun bracedBlock(source: String, marker: String): String { + val markerIndex = source.indexOf(marker) + assertTrue(markerIndex >= 0, marker) + val open = source.indexOf('{', markerIndex) + assertTrue(open >= 0, "$marker opening brace") + var depth = 0 + for (index in open until source.length) { + when (source[index]) { + '{' -> depth += 1 + '}' -> { + depth -= 1 + if (depth == 0) return source.substring(open + 1, index) + } + } + } + error("Unclosed block: $marker") + } + + private fun parenthesizedCall(source: String, marker: String): String { + val markerIndex = source.indexOf(marker) + assertTrue(markerIndex >= 0, marker) + val open = source.indexOf('(', markerIndex) + var depth = 0 + for (index in open until source.length) { + when (source[index]) { + '(' -> depth += 1 + ')' -> { + depth -= 1 + if (depth == 0) return source.substring(markerIndex, index + 1) + } + } + } + error("Unclosed call: $marker") + } + + private fun mutationKindBranch(successBlock: String, kind: String): String { + val marker = "ProfilePreferenceMutationKind.$kind" + val start = successBlock.indexOf(marker) + assertTrue(start >= 0, marker) + val arrow = successBlock.indexOf("->", start) + assertTrue(arrow >= 0, "$marker branch") + val open = successBlock.indexOf('{', arrow) + return if (open >= 0 && open - arrow < 16) { + bracedBlock(successBlock.substring(arrow), "->") + } else { + successBlock.substring(arrow, successBlock.indexOf('\n', arrow).takeIf { it >= 0 } + ?: successBlock.length) + } + } + + private fun assertCurrentProfileAndTrackedTokenBeforeKind( + branch: String, + label: String, + ) { + assertTrue( + Regex("""event\.profileId\s*==\s*readyProfileId|readyProfileId\s*==\s*event\.profileId""") + .containsMatchIn(branch), + "the $label branch itself must reject a non-current profile", + ) + assertTrue( + Regex( + """(?:containsKey|contains|remove|get|\[)\s*\(?\s*event\.token|event\.token\s+in\s+""", + ).containsMatchIn(branch), + "the $label branch itself must consume or verify the tracked token", + ) + val kindDispatch = branch.indexOf("event.kind") + assertTrue(kindDispatch >= 0, "$label kind dispatch") + assertTrue(branch.indexOf("event.profileId") in 0 until kindDispatch) + assertTrue(branch.indexOf("event.token") in 0 until kindDispatch) + } + + private fun assertSliderContract( + source: String, + authoritativeField: String, + section: String, + finalCallback: String, + ) { + val authoritative = requireNotNull( + Regex( + """val\s+(\w+)\s*=\s*[^\n]*\b$authoritativeField\b""", + ).find(source), + ) { "$authoritativeField needs an explicit authoritative value" }.groupValues[1] + val draft = requireNotNull( + Regex( + """var\s+(\w*[Dd]raft\w*)\s+by\s+rememberSaveable\s*\(\s*profileId\s*,\s*${Regex.escape(authoritative)}\s*\)""", + ).find(source), + ) { "$authoritativeField needs a profile and authoritative-value keyed local draft" } + .groupValues[1] + assertTrue( + Regex( + """LaunchedEffect\s*\(\s*profileId\s*,\s*${Regex.escape(authoritative)}\s*\)\s*\{[\s\S]{0,180}${Regex.escape(draft)}\s*=\s*${Regex.escape(authoritative)}""", + ).containsMatchIn(source), + "$authoritativeField draft must resynchronize from authoritative Ready", + ) + assertTrue( + Regex( + """enabled\s*=\s*[^\n]{0,180}ProfilePreferenceSection\.$section[^\n]{0,100}busySections""", + ).containsMatchIn(source), + "$section slider must disable while its section is busy", + ) + assertTrue( + Regex( + """onValueChangeFinished\s*=\s*\{[\s\S]{0,700}$finalCallback\s*\([\s\S]{0,300}\.copy\s*\([\s\S]{0,220}$authoritativeField\s*=\s*${Regex.escape(draft)}""", + ).containsMatchIn(source), + "$authoritativeField must commit one final whole-section copy", + ) + } +} diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ProfileSettingsSeparationContractTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ProfileSettingsSeparationContractTest.kt new file mode 100644 index 000000000..a83f7f2fc --- /dev/null +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ProfileSettingsSeparationContractTest.kt @@ -0,0 +1,694 @@ +package com.devil.phoenixproject.presentation.screen + +import com.devil.phoenixproject.testutil.readProjectFile +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ProfileSettingsSeparationContractTest { + private val settingsPath = + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/SettingsTab.kt" + private val profilePath = + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ProfileScreen.kt" + private val dialogsPath = + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileSafetyDialogs.kt" + private val navGraphPath = + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/navigation/NavGraph.kt" + private val mainViewModelPath = + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/MainViewModel.kt" + + private val settingsForbiddenSymbols = listOf( + "weightUnit", + "onWeightUnitChange", + "weightIncrement", + "onWeightIncrementChange", + "bodyWeightKg", + "onBodyWeightKgChange", + "onNavigateToEquipmentRack", + "audioRepCountEnabled", + "onAudioRepCountChange", + "summaryCountdownSeconds", + "onSummaryCountdownChange", + "autoStartCountdownSeconds", + "onAutoStartCountdownChange", + "countdownBeepsEnabled", + "onCountdownBeepsChange", + "repSoundEnabled", + "onRepSoundChange", + "motionStartEnabled", + "onMotionStartChange", + "autoStartRoutine", + "onAutoStartRoutineChange", + "weightSuggestionsEnabled", + "onWeightSuggestionsEnabledChange", + "selectedColorSchemeIndex", + "onColorSchemeChange", + "discoModeUnlocked", + "discoModeActive", + "isConnected", + "onDiscoModeUnlocked", + "onDiscoModeToggle", + "onPlayDiscoSound", + "gamificationEnabled", + "onGamificationEnabledChange", + "voiceStopEnabled", + "onVoiceStopEnabledChange", + "safeWord", + "onSafeWordChange", + "safeWordCalibrated", + "onSafeWordCalibratedChange", + "velocityLossThresholdPercent", + "onVelocityLossThresholdChange", + "autoEndOnVelocityLoss", + "onAutoEndOnVelocityLossChange", + "stallDetectionEnabled", + "defaultScalingBasis", + "onDefaultScalingBasisChange", + "defaultRoutineExerciseUsePercentOfPR", + "onDefaultRoutineExerciseUsePercentOfPRChange", + "defaultRoutineExerciseWeightPercentOfPR", + "onDefaultRoutineExerciseWeightPercentOfPRChange", + "verbalEncouragementEnabled", + "onVerbalEncouragementEnabledChange", + "vulgarModeEnabled", + "onVulgarModeEnabledChange", + "vulgarTier", + "onVulgarTierChange", + "dominatrixModeUnlocked", + "onDominatrixModeUnlockedChange", + "dominatrixModeActive", + "onDominatrixModeActiveChange", + "adultsOnlyConfirmed", + "onConfirmAdultsAndEnableVulgar", + "adultsOnlyPrompted", + "onAdultsOnlyPromptedChange", + "onPlayDominatrixUnlockSound", + "onNavigateToBadges", + "UserProfileRepository", + "ExternalMeasurementRepository", + "SafeWordCalibrationDialog", + "AdultsOnlyConfirmDialog", + "DominatrixUnlockDialog", + "DiscoModeUnlockDialog", + "activeProfileId", + "healthBodyWeightMeasurements", + "latestMatchingHealthBodyWeight", + "localWeightUnit", + "showWeightIncrementDialog", + "showBodyWeightDialog", + "bodyWeightInput", + "localSafeWord", + "showCalibrationDialog", + "easterEggTapCount", + "lastTapTime", + "showDiscoUnlockDialog", + "dominatrixEasterEggTapCount", + "lastDominatrixTapTime", + "showDominatrixUnlockDialog", + "showAdultsOnlyDialog", + "onCancelAutoConnecting", + ) + + private val mainViewModelProfileWriteRemovals = listOf( + "setWeightUnit", + "setStopAtTop", + "setStallDetectionEnabled", + "setAudioRepCountEnabled", + "setRepCountTiming", + "setSummaryCountdownSeconds", + "setAutoStartCountdownSeconds", + "setColorScheme", + "setWeightIncrement", + "setAutoStartRoutine", + "setBodyWeightKg", + "setGamificationEnabled", + "setCountdownBeepsEnabled", + "setRepSoundEnabled", + "setMotionStartEnabled", + "setVoiceStopEnabled", + "setSafeWord", + "setSafeWordCalibrated", + "setVelocityLossThreshold", + "setAutoEndOnVelocityLoss", + "setWeightSuggestionsEnabled", + "setDefaultScalingBasis", + "setDefaultRoutineExerciseUsePercentOfPR", + "setDefaultRoutineExerciseWeightPercentOfPR", + "setVerbalEncouragementEnabled", + "setVulgarModeEnabled", + "setVulgarTier", + "setDominatrixModeUnlocked", + "setDominatrixModeActive", + "setAdultsOnlyConfirmed", + "confirmAdultsAndEnableVulgar", + "setAdultsOnlyPrompted", + "isAdultsOnlyPrompted", + "unlockDiscoMode", + "cancelAutoConnecting", + ) + + @Test + fun settingsTabExposesExactGlobalOnlySignature() { + val signature = functionSignature(source(settingsPath), "SettingsTab") + val expected = """ + fun SettingsTab( + enableVideoPlayback: Boolean, + themeMode: ThemeMode, + dynamicColorAvailable: Boolean, + dynamicColorEnabled: Boolean, + onEnableVideoPlaybackChange: (Boolean) -> Unit, + onThemeModeChange: (ThemeMode) -> Unit, + onDynamicColorEnabledChange: (Boolean) -> Unit, + onDeleteAllWorkouts: () -> Unit, + onNavigateToConnectionLogs: () -> Unit, + onNavigateToDiagnostics: () -> Unit, + onNavigateToLinkAccount: () -> Unit, + onNavigateToIntegrations: () -> Unit, + connectionError: String?, + onClearConnectionError: () -> Unit, + onSetTitle: (String) -> Unit, + onTestSounds: () -> Unit, + bleCompatibilityMode: BleCompatibilitySetting, + onBleCompatibilityModeChange: (BleCompatibilitySetting) -> Unit, + autoBackupEnabled: Boolean, + onAutoBackupEnabledChange: (Boolean) -> Unit, + backupStats: BackupStats?, + onOpenBackupFolder: () -> Unit, + backupDestination: BackupDestination, + onBackupDestinationChange: (BackupDestination) -> Unit, + selectedLanguage: String, + onLanguageChange: (String) -> Unit, + modifier: Modifier = Modifier, + ) + """.trimIndent() + + assertEquals(compactKotlin(expected), compactKotlin(signature)) + } + + @Test + fun settingsContainsNoCanonicalProfileOwnedSymbolOrModalState() { + assertNoCanonicalSymbols( + functionSource(source(settingsPath), "SettingsTab"), + settingsForbiddenSymbols, + "SettingsTab", + ) + } + + @Test + fun settingsRemovesOnlyTask8DialogCallSitesAndKeepsSharedDialogOwnership() { + val settings = source(settingsPath) + val settingsTab = functionSource(settings, "SettingsTab") + val dialogs = source(dialogsPath) + val sharedDialogs = listOf( + "SafeWordCalibrationDialog", + "AdultsOnlyConfirmDialog", + "DominatrixUnlockDialog", + "DiscoModeUnlockDialog", + ) + + sharedDialogs.forEach { name -> + assertFalse( + Regex("(?m)^\\s*import\\s+[^\\n]*\\.$name\\s*$").containsMatchIn(settings), + "Settings must not import $name", + ) + assertFalse( + Regex("\\b$name\\s*\\(").containsMatchIn(settingsTab), + "Settings must not call $name", + ) + assertEquals( + 1, + Regex("(?m)^fun\\s+$name\\s*\\(").findAll(dialogs).count(), + "$name must remain shared exactly once", + ) + } + assertContains(dialogs, "DisposableEffect(safeWord)") + } + + @Test + fun globalSectionsStayInRequiredOrderAndVideoUsesLocalizedResources() { + val settings = source(settingsPath) + val body = functionBody(settings, "SettingsTab") + assertInOrder( + body, + listOf( + "Res.string.cd_support_developer", + "Res.string.settings_cloud_sync", + "Res.string.settings_appearance", + "Res.string.settings_language", + "Res.string.settings_video_behavior", + "onDeleteAllWorkouts", + "onNavigateToDiagnostics", + "Res.string.settings_version", + ), + ) + listOf( + "onNavigateToLinkAccount", + "onNavigateToIntegrations", + "autoBackupEnabled", + "backupDestination", + "backupStats", + "onOpenBackupFolder", + "showBackupDialog = true", + "showRestoreDialog = true", + "backupManager.shareBackup()", + "backupManager.importFromFile(", + "bleCompatibilityMode", + "onBleCompatibilityModeChange", + "onNavigateToConnectionLogs", + "onNavigateToDiagnostics", + "onTestSounds", + ).forEach { retained -> assertContains(body, retained) } + + val videoKeys = listOf( + "settings_video_behavior", + "settings_show_exercise_videos", + "settings_show_exercise_videos_description", + ) + videoKeys.forEach { key -> assertContains(body, "Res.string.$key") } + listOf("values", "values-de", "values-es", "values-fr", "values-nl") + .forEach { locale -> + val xml = source("src/commonMain/composeResources/$locale/strings.xml") + videoKeys.forEach { key -> + assertEquals( + 1, + Regex(""")""").findAll(xml).count(), + "$locale:$key", + ) + } + } + + assertContains(settings, "private fun GlobalSettingsSectionCard(") + val switchRow = functionSource(settings, "GlobalSettingsSwitchRow") + listOf("defaultMinSize(minHeight = 48.dp)", ".toggleable(", "role = Role.Switch") + .forEach { contract -> assertContains(switchRow, contract) } + assertTrue( + Regex("""Switch\s*\([^)]*onCheckedChange\s*=\s*null""", RegexOption.DOT_MATCHES_ALL) + .containsMatchIn(switchRow), + "the child Switch must not install a second callback", + ) + } + + @Test + fun profileOwnsAchievementsRackAndPreferenceSectionsInRequiredOrder() { + val profile = source(profilePath) + val settings = functionSource(source(settingsPath), "SettingsTab") + val itemAnchors = listOf( + "item(key = \"profile-header\")", + "item(key = \"exercise-insights\")", + "item(key = \"achievements\")", + "item(key = \"preferences-heading\")", + "item(key = \"profile-preferences\")", + ) + assertInOrder(profile, itemAnchors) + listOf( + "ProfilePreferenceSections(", + "onNavigateToEquipmentRack", + "onNavigateToBadges", + "TestTags.ACTION_BADGES", + ).forEach { contract -> assertContains(profile, contract) } + + val achievements = profile.substring( + profile.indexOf(itemAnchors[2]), + profile.indexOf(itemAnchors[3]), + ) + assertFalse(achievements.contains("gamificationEnabled")) + listOf( + "ProfilePreferenceSections(", + "onNavigateToEquipmentRack", + "onNavigateToBadges", + "equipment_rack_title", + "TestTags.ACTION_BADGES", + ).forEach { forbidden -> + assertFalse(settings.contains(forbidden), "Settings retained $forbidden") + } + } + + @Test + fun settingsDestinationCollectsOnlyGlobalSettingsConnectionErrorAndBackupStats() { + val settingsDestination = destinationSource(source(navGraphPath), "Settings") + listOf( + "val globalSettings by viewModel.globalSettings.collectAsState()", + "val connectionError by viewModel.connectionError.collectAsState()", + "val backupStats by viewModel.backupStats.collectAsState()", + "viewModel.refreshBackupStats()", + ).forEach { contract -> assertContains(settingsDestination, contract) } + assertEquals( + 3, + Regex("\\.collectAsState\\s*\\(").findAll(settingsDestination).count(), + "Settings must collect exactly three flows", + ) + assertNoCanonicalSymbols(settingsDestination, settingsForbiddenSymbols, "Settings destination") + + val call = parenthesizedCall(settingsDestination, "SettingsTab(") + listOf( + "enableVideoPlayback = globalSettings.enableVideoPlayback", + "themeMode = themeMode", + "dynamicColorAvailable = dynamicColorAvailable", + "dynamicColorEnabled = dynamicColorEnabled", + "bleCompatibilityMode = globalSettings.bleCompatibilityMode", + "autoBackupEnabled = globalSettings.autoBackupEnabled", + "backupStats = backupStats", + "backupDestination = globalSettings.backupDestination", + "selectedLanguage = globalSettings.language", + "connectionError = connectionError", + ).forEach { assignment -> + assertTrue( + compactKotlin(call).contains(compactKotlin(assignment)), + "missing Settings wiring: $assignment", + ) + } + listOf( + "onEnableVideoPlaybackChange" to "setEnableVideoPlayback", + "onDeleteAllWorkouts" to "deleteAllWorkouts", + "onClearConnectionError" to "clearConnectionError", + "onSetTitle" to "updateTopBarTitle", + "onTestSounds" to "testSounds", + "onBleCompatibilityModeChange" to "setBleCompatibilityMode", + "onAutoBackupEnabledChange" to "setAutoBackupEnabled", + "onOpenBackupFolder" to "openBackupFolder", + "onBackupDestinationChange" to "setBackupDestination", + "onLanguageChange" to "setLanguage", + ).forEach { (parameter, method) -> assertViewModelCallback(call, parameter, method) } + listOf( + "onThemeModeChange = onThemeModeChange", + "onDynamicColorEnabledChange = onDynamicColorEnabledChange", + "onNavigateToConnectionLogs" to "NavigationRoutes.ConnectionLogs.route", + "onNavigateToDiagnostics" to "NavigationRoutes.Diagnostics.route", + "onNavigateToLinkAccount" to "NavigationRoutes.LinkAccount.route", + "onNavigateToIntegrations" to "NavigationRoutes.Integrations.route", + ).forEach { contract -> + when (contract) { + is String -> assertTrue(compactKotlin(call).contains(compactKotlin(contract))) + is Pair<*, *> -> assertNavigationCallback( + call, + contract.first as String, + contract.second as String, + ) + } + } + } + + @Test + fun mainViewModelExposesNoProfileWriteWrappersAndKeepsRequiredReadAndTransientApis() { + val main = source(mainViewModelPath) + val forbidden = mainViewModelProfileWriteRemovals.filter { name -> + Regex("(?m)^\\s*(?:(?:public|internal|private|protected)\\s+)?fun\\s+$name\\s*\\(") + .containsMatchIn(main) || Regex("::\\s*$name\\b").containsMatchIn(main) + } + assertTrue(forbidden.isEmpty(), "MainViewModel retained profile writes: $forbidden") + assertTrue( + Regex("""\bprivate\s+val\s+settingsManager\b""").containsMatchIn(main), + "settingsManager must be private", + ) + assertFalse( + Regex("(?m)^\\s*(?:public\\s+)?val\\s+settingsManager\\b").containsMatchIn(main), + "settingsManager must not be public", + ) + + listOf("userPreferences", "weightUnit", "enableVideoPlayback", "autoplayEnabled", "globalSettings") + .forEach { property -> + assertTrue( + Regex("(?m)^\\s*(?:public\\s+)?val\\s+$property\\b").containsMatchIn(main), + "missing retained read: $property", + ) + } + listOf( + "setEnableVideoPlayback", + "setBleCompatibilityMode", + "setAutoBackupEnabled", + "setBackupDestination", + "setLanguage", + "deleteAllWorkouts", + "clearConnectionError", + "updateTopBarTitle", + "testSounds", + "refreshBackupStats", + "openBackupFolder", + "toggleDiscoMode", + "emitDiscoSound", + "emitDominatrixUnlockSound", + ).forEach { function -> + assertTrue( + Regex("(?m)^\\s*(?:public\\s+)?fun\\s+$function\\s*\\(").containsMatchIn(main), + "missing retained API: $function", + ) + } + val mapper = settingsGlobalMapperSource(main) + val expectedMapper = """ + private fun UserPreferences.toSettingsGlobalUiState() = SettingsGlobalUiState( + enableVideoPlayback = enableVideoPlayback, + bleCompatibilityMode = bleCompatibilityMode, + autoBackupEnabled = autoBackupEnabled, + backupDestination = backupDestination, + language = language, + ) + """.trimIndent() + assertEquals(compactKotlin(expectedMapper), compactKotlin(mapper)) + + val initializer = globalSettingsInitializer(main) + assertTrue( + compactKotlin(initializer).startsWith( + "preferencesManager.preferencesFlow" + + ".map(UserPreferences::toSettingsGlobalUiState).stateIn(", + ), + "globalSettings must map raw global preferences directly before stateIn", + ) + assertFalse(initializer.contains("settingsManager")) + assertFalse(initializer.contains("userPreferences")) + } + + @Test + fun profileOwnsRackAndBadgesNavigationWhileDestinationsRemainUnique() { + val graph = source(navGraphPath) + val profile = destinationSource(graph, "Profile") + val settings = destinationSource(graph, "Settings") + listOf("EquipmentRack", "Badges").forEach { destination -> + val navigation = "navController.navigate(NavigationRoutes.$destination.route)" + assertContains(profile, navigation) + assertFalse(settings.contains(navigation), "Settings must not navigate to $destination") + assertEquals(1, Regex(Regex.escape(navigation)).findAll(graph).count(), navigation) + assertEquals( + 1, + Regex("route\\s*=\\s*NavigationRoutes\\.$destination\\.route") + .findAll(graph) + .count(), + "$destination destination registration", + ) + } + } + + @Test + fun profileSwitcherRemainsTheOnlyTaskOwnedPresentationRepositoryCaller() { + fun source(path: String): String = requireNotNull(readProjectFile(path)) { path } + + val switcherPath = + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileSwitcherViewModel.kt" + val mainPath = + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/EnhancedMainScreen.kt" + val justLiftPath = + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/JustLiftScreen.kt" + val graphPath = + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/navigation/NavGraph.kt" + val profilePath = + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ProfileScreen.kt" + val settingsPath = + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/SettingsTab.kt" + val mainViewModelPath = + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/MainViewModel.kt" + val sheetPath = + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileSwitcherSheet.kt" + val dialogsPath = + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileDialogs.kt" + val listItemPath = + "src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ProfileListItem.kt" + + val switcher = source(switcherPath) + val main = source(mainPath) + val graph = source(graphPath) + val profile = source(profilePath) + val sheet = source(sheetPath) + val dialogs = source(dialogsPath) + val listItem = source(listItemPath) + + listOf( + "profiles.setActiveProfile(", + "profiles.createAndActivateProfile(", + "profiles.reconcileActiveProfileContext(", + ).forEach { call -> + assertEquals(1, Regex(Regex.escape(call)).findAll(switcher).count(), call) + } + + val directRepositoryCall = Regex( + """\b(?:profiles|profileRepository|userProfileRepository)\s*\.\s*""" + + """(?:setActiveProfile|createAndActivateProfile|reconcileActiveProfileContext)\s*\(""", + ) + mapOf( + mainPath to main, + justLiftPath to source(justLiftPath), + graphPath to graph, + profilePath to profile, + settingsPath to source(settingsPath), + mainViewModelPath to source(mainViewModelPath), + sheetPath to sheet, + dialogsPath to dialogs, + listItemPath to listItem, + ).forEach { (path, text) -> + assertFalse(directRepositoryCall.containsMatchIn(text), path) + } + + assertEquals(1, Regex("""\bProfileSwitcherSheet\s*\(""").findAll(main).count()) + assertContains(main, "profileSwitcherViewModel.openSwitcher()") + assertContains(graph, "onOpenProfileSwitcher = onOpenProfileSwitcher") + assertContains(profile, "onOpenProfileSwitcher") + assertFalse(sheet.contains("UserProfileRepository")) + assertFalse(dialogs.contains("UserProfileRepository")) + assertFalse(listItem.contains("UserProfileRepository")) + assertFalse(listItem.contains("onLongClick")) + assertFalse(listItem.contains("combinedClickable")) + } + + private fun source(path: String): String = requireNotNull(readProjectFile(path)) { path } + + private fun functionSignature(source: String, name: String): String { + val start = source.indexOf("fun $name(") + assertTrue(start >= 0, "missing function $name") + val open = source.indexOf('(', start) + val close = matchingClose(source, open, '(', ')') + return source.substring(start, close + 1) + } + + private fun functionSource(source: String, name: String): String { + val signature = functionSignature(source, name) + val start = source.indexOf(signature) + val open = source.indexOf('{', start + signature.length) + assertTrue(open >= 0, "$name body") + val close = matchingClose(source, open, '{', '}') + return source.substring(start, close + 1) + } + + private fun functionBody(source: String, name: String): String { + val function = functionSource(source, name) + return function.substring(function.indexOf('{') + 1, function.lastIndexOf('}')) + } + + private fun settingsGlobalMapperSource(source: String): String { + val marker = "private fun UserPreferences.toSettingsGlobalUiState()" + val start = source.indexOf(marker) + assertTrue(start >= 0, "missing private raw-global mapper") + val equals = source.indexOf('=', start + marker.length) + assertTrue(equals >= 0, "raw-global mapper initializer") + val expressionStart = source.indexOfFirstNonWhitespace(equals + 1) + assertTrue( + source.startsWith("SettingsGlobalUiState(", expressionStart), + "raw-global mapper must directly construct SettingsGlobalUiState", + ) + val open = source.indexOf('(', expressionStart) + val close = matchingClose(source, open, '(', ')') + return source.substring(start, close + 1) + } + + private fun globalSettingsInitializer(source: String): String { + val declaration = Regex( + """(?m)^\s*val\s+globalSettings\s*:\s*StateFlow\s*=""", + ).find(source) + assertTrue(declaration != null, "missing typed globalSettings declaration") + val expressionStart = source.indexOfFirstNonWhitespace(declaration.range.last + 1) + assertTrue( + source.startsWith("preferencesManager.preferencesFlow", expressionStart), + "globalSettings must start from preferencesManager.preferencesFlow", + ) + val stateIn = source.indexOf(".stateIn(", expressionStart) + assertTrue( + stateIn in expressionStart..(expressionStart + 500), + "globalSettings initializer must terminate in a nearby stateIn call", + ) + val open = source.indexOf('(', stateIn) + val close = matchingClose(source, open, '(', ')') + return source.substring(expressionStart, close + 1) + } + + private fun String.indexOfFirstNonWhitespace(startIndex: Int): Int { + for (index in startIndex until length) { + if (!this[index].isWhitespace()) return index + } + error("missing initializer expression") + } + + private fun destinationSource(source: String, route: String): String { + val marker = "route = NavigationRoutes.$route.route" + val markerIndex = source.indexOf(marker) + assertTrue(markerIndex >= 0, marker) + val callStart = source.lastIndexOf("composable(", markerIndex) + assertTrue(callStart >= 0, "$route composable") + val openParenthesis = source.indexOf('(', callStart) + val closeParenthesis = matchingClose(source, openParenthesis, '(', ')') + val openBody = source.indexOf('{', closeParenthesis) + assertTrue(openBody >= 0, "$route destination body") + val closeBody = matchingClose(source, openBody, '{', '}') + return source.substring(callStart, closeBody + 1) + } + + private fun parenthesizedCall(source: String, marker: String): String { + val start = source.indexOf(marker) + assertTrue(start >= 0, marker) + val open = source.indexOf('(', start) + val close = matchingClose(source, open, '(', ')') + return source.substring(start, close + 1) + } + + private fun matchingClose(source: String, open: Int, opener: Char, closer: Char): Int { + var depth = 0 + for (index in open until source.length) { + when (source[index]) { + opener -> depth += 1 + closer -> { + depth -= 1 + if (depth == 0) return index + } + } + } + error("Unclosed $opener at $open") + } + + private fun compactKotlin(source: String): String = source + .replace(Regex("""//[^\n]*|/\*[\s\S]*?\*/"""), "") + .replace(Regex("""\s+"""), "") + .replace(",)", ")") + + private fun assertNoCanonicalSymbols(source: String, symbols: List, label: String) { + val hits = symbols.filter { symbol -> + Regex("(?) { + var previous = -1 + anchors.forEach { anchor -> + val index = source.indexOf(anchor) + assertTrue(index > previous, "$anchor is missing or out of order") + previous = index + } + } + + private fun assertViewModelCallback(call: String, parameter: String, method: String) { + assertTrue( + Regex( + """\b${Regex.escape(parameter)}\s*=\s*(?:viewModel::${Regex.escape(method)}|\{[\s\S]{0,100}?viewModel\.${Regex.escape(method)}\s*\([^}]*\)\s*})""", + ).containsMatchIn(call), + "$parameter must call MainViewModel.$method", + ) + } + + private fun assertNavigationCallback(call: String, parameter: String, route: String) { + assertTrue( + Regex( + """\b${Regex.escape(parameter)}\s*=\s*\{[\s\S]{0,140}?navController\.navigate\s*\(\s*${Regex.escape(route)}\s*\)\s*}""", + ).containsMatchIn(call), + "$parameter must navigate to $route", + ) + } +} diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/DWSMTestHarness.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/DWSMTestHarness.kt index 4703bddb0..d9900e2bb 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/DWSMTestHarness.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/DWSMTestHarness.kt @@ -1,6 +1,8 @@ package com.devil.phoenixproject.testutil -import com.devil.phoenixproject.data.repository.SettingsEquipmentRackRepository +import com.devil.phoenixproject.data.repository.ProfileEquipmentRackRepository +import com.devil.phoenixproject.data.repository.ActiveProfileContext +import com.devil.phoenixproject.domain.model.UserPreferences import com.devil.phoenixproject.domain.model.HapticEvent import com.devil.phoenixproject.domain.usecase.ApplyEquipmentRackLoadUseCase import com.devil.phoenixproject.domain.usecase.ApplyRoutineModifierUseCase @@ -13,7 +15,6 @@ import com.devil.phoenixproject.presentation.manager.GamificationManager import com.devil.phoenixproject.presentation.manager.SettingsManager import com.devil.phoenixproject.presentation.manager.WorkoutServiceController import com.devil.phoenixproject.presentation.manager.WorkoutServiceSnapshot -import com.russhwolf.settings.MapSettings import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow @@ -67,8 +68,7 @@ class DWSMTestHarness(val testScope: TestScope) { val fakeRepMetricRepo = FakeRepMetricRepository() val fakeBiomechanicsRepo = FakeBiomechanicsRepository() val fakeWorkoutServiceController = FakeWorkoutServiceController() - val fakeEquipmentRackRepo = SettingsEquipmentRackRepository(MapSettings()) - val fakeUserProfileRepo = FakeUserProfileRepository() + val fakeUserProfileRepo = FakeUserProfileRepository().apply { setActiveProfileForTest() } val repCounter = RepCounterFromMachine() val resolveWeightsUseCase = ResolveRoutineWeightsUseCase(fakePRRepo, fakeExerciseRepo, FakeVelocityOneRepMaxRepository()) @@ -86,7 +86,8 @@ class DWSMTestHarness(val testScope: TestScope) { private val dwsmScope = CoroutineScope(StandardTestDispatcher(testScope.testScheduler) + dwsmJob) val workoutScope: CoroutineScope get() = dwsmScope - val settingsManager = SettingsManager(fakePrefsManager, fakeBleRepo, dwsmScope) + val fakeEquipmentRackRepo = ProfileEquipmentRackRepository(fakeUserProfileRepo, dwsmScope) + val settingsManager = SettingsManager(fakePrefsManager, fakeUserProfileRepo, dwsmScope) val gamificationManager = GamificationManager( fakeGamificationRepo, fakePRRepo, @@ -139,6 +140,109 @@ class DWSMTestHarness(val testScope: TestScope) { /** Convenience accessor for the active session engine (workout lifecycle, BLE, auto-stop, rest timer) */ val activeSessionEngine get() = dwsm.activeSessionEngine + private fun readyProfile(): ActiveProfileContext.Ready = + fakeUserProfileRepo.activeProfileContext.value as ActiveProfileContext.Ready + + suspend fun setActiveBodyWeightKg(value: Float) { + val ready = readyProfile() + fakeUserProfileRepo.updateCore( + ready.profile.id, + ready.preferences.core.value.copy(bodyWeightKg = value), + ) + } + + suspend fun setActiveSummaryCountdownSeconds(value: Int) { + val ready = readyProfile() + fakeUserProfileRepo.updateWorkout( + ready.profile.id, + ready.preferences.workout.value.copy(summaryCountdownSeconds = value), + ) + } + + suspend fun setActiveCountdownBeepsEnabled(value: Boolean) { + val ready = readyProfile() + fakeUserProfileRepo.updateWorkout( + ready.profile.id, + ready.preferences.workout.value.copy(countdownBeepsEnabled = value), + ) + } + + suspend fun setActiveProfilePreferences(value: UserPreferences) { + val initial = readyProfile() + fakeUserProfileRepo.updateCore( + initial.profile.id, + initial.preferences.core.value.copy( + bodyWeightKg = value.bodyWeightKg, + weightUnit = value.weightUnit, + weightIncrement = value.weightIncrement, + ), + ) + var ready = readyProfile() + fakeUserProfileRepo.updateWorkout( + ready.profile.id, + ready.preferences.workout.value.copy( + stopAtTop = value.stopAtTop, + beepsEnabled = value.beepsEnabled, + audioRepCountEnabled = value.audioRepCountEnabled, + repCountTiming = value.repCountTiming, + summaryCountdownSeconds = value.summaryCountdownSeconds, + autoStartCountdownSeconds = value.autoStartCountdownSeconds, + gamificationEnabled = value.gamificationEnabled, + autoStartRoutine = value.autoStartRoutine, + countdownBeepsEnabled = value.countdownBeepsEnabled, + repSoundEnabled = value.repSoundEnabled, + motionStartEnabled = value.motionStartEnabled, + weightSuggestionsEnabled = value.weightSuggestionsEnabled, + defaultRoutineExerciseUsePercentOfPR = value.defaultRoutineExerciseUsePercentOfPR, + defaultRoutineExerciseWeightPercentOfPR = value.defaultRoutineExerciseWeightPercentOfPR, + voiceStopEnabled = value.voiceStopEnabled, + ), + ) + ready = readyProfile() + fakeUserProfileRepo.updateLed( + ready.profile.id, + ready.preferences.led.value.copy( + colorScheme = value.colorScheme, + discoModeUnlocked = value.discoModeUnlocked, + ), + ) + ready = readyProfile() + fakeUserProfileRepo.updateVbt( + ready.profile.id, + ready.preferences.vbt.value.copy( + enabled = value.vbtEnabled, + velocityLossThresholdPercent = value.velocityLossThresholdPercent, + autoEndOnVelocityLoss = value.autoEndOnVelocityLoss, + defaultScalingBasis = value.defaultScalingBasis, + verbalEncouragementEnabled = value.verbalEncouragementEnabled, + vulgarModeEnabled = value.vulgarModeEnabled, + vulgarTier = value.vulgarTier, + dominatrixModeUnlocked = value.dominatrixModeUnlocked, + dominatrixModeActive = value.dominatrixModeActive, + ), + ) + ready = readyProfile() + fakeUserProfileRepo.updateLocalSafety( + ready.profile.id, + ready.localSafety.copy( + safeWord = value.safeWord, + safeWordCalibrated = value.safeWordCalibrated, + adultsOnlyConfirmed = value.adultsOnlyConfirmed, + adultsOnlyPrompted = value.adultsOnlyPrompted, + ), + ) + fakePrefsManager.setPreferences( + fakePrefsManager.preferencesFlow.value.copy( + enableVideoPlayback = value.enableVideoPlayback, + autoBackupEnabled = value.autoBackupEnabled, + backupDestination = value.backupDestination, + language = value.language, + velocityOneRepMaxBackfillDone = value.velocityOneRepMaxBackfillDone, + bleCompatibilityMode = value.bleCompatibilityMode, + ), + ) + } + /** * Cancel all DWSM coroutines to prevent UncompletedCoroutinesError. * Call this at the end of each test after assertions are complete. diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeAssessmentRepository.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeAssessmentRepository.kt new file mode 100644 index 000000000..0cbdaf88f --- /dev/null +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeAssessmentRepository.kt @@ -0,0 +1,101 @@ +package com.devil.phoenixproject.testutil + +import com.devil.phoenixproject.data.repository.AssessmentRepository +import com.devil.phoenixproject.data.repository.AssessmentResultEntity +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +class FakeAssessmentRepository : AssessmentRepository { + data class SavedSession( + val exerciseId: String, + val estimatedOneRepMaxTotalKg: Float, + val userOverrideTotalKg: Float?, + val weightPerCableKg: Float, + val profileId: String, + ) + + val attemptedSessions = mutableListOf() + val savedSessions = mutableListOf() + var saveSessionFailure: Throwable? = null + var latestAssessmentFailure: Throwable? = null + private val assessments = mutableListOf() + + override suspend fun saveAssessment( + exerciseId: String, + estimatedOneRepMaxKg: Float, + loadVelocityDataJson: String, + sessionId: String?, + userOverrideKg: Float?, + profileId: String, + ): Long { + require(profileId.isNotBlank()) + val id = assessments.size.toLong() + 1L + assessments += AssessmentResultEntity( + id = id, + exerciseId = exerciseId, + estimatedOneRepMaxKg = estimatedOneRepMaxKg, + loadVelocityData = loadVelocityDataJson, + assessmentSessionId = sessionId, + userOverrideKg = userOverrideKg, + createdAt = id, + profileId = profileId, + ) + return id + } + + override fun getAssessmentsByExercise( + exerciseId: String, + profileId: String, + ): Flow> = flowOf( + assessments.filter { it.exerciseId == exerciseId && it.profileId == profileId } + .sortedByDescending { it.createdAt }, + ) + + override suspend fun getLatestAssessment( + exerciseId: String, + profileId: String, + ): AssessmentResultEntity? { + latestAssessmentFailure?.let { throw it } + return assessments + .filter { it.exerciseId == exerciseId && it.profileId == profileId } + .maxByOrNull { it.createdAt } + } + + override suspend fun deleteAssessment(id: Long) { + assessments.removeAll { it.id == id } + } + + override suspend fun saveAssessmentSession( + exerciseId: String, + exerciseName: String, + estimatedOneRepMaxKg: Float, + loadVelocityDataJson: String, + userOverrideKg: Float?, + totalReps: Int, + durationMs: Long, + weightPerCableKg: Float, + profileId: String, + ): String { + require(profileId.isNotBlank()) + val captured = SavedSession( + exerciseId = exerciseId, + estimatedOneRepMaxTotalKg = estimatedOneRepMaxKg, + userOverrideTotalKg = userOverrideKg, + weightPerCableKg = weightPerCableKg, + profileId = profileId, + ) + attemptedSessions += captured + saveSessionFailure?.let { throw it } + savedSessions += captured + val sessionId = "assessment-session-${savedSessions.size}" + saveAssessment( + exerciseId = exerciseId, + estimatedOneRepMaxKg = estimatedOneRepMaxKg, + loadVelocityDataJson = loadVelocityDataJson, + sessionId = sessionId, + userOverrideKg = userOverrideKg, + profileId = profileId, + ) + return sessionId + } +} diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeExternalIntegrationRepositories.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeExternalIntegrationRepositories.kt index bc579fa52..b94a59477 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeExternalIntegrationRepositories.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeExternalIntegrationRepositories.kt @@ -166,7 +166,16 @@ class FakeExternalProgramRepository : ExternalProgramRepository { } class FakeExternalMeasurementRepository : ExternalMeasurementRepository { + data class MeasurementObservationRequest( + val profileId: String, + val measurementType: String, + ) + val measurements = mutableListOf() + val observationRequests = mutableListOf() + var upsertFailure: Throwable? = null + var observeByTypeOverride: + ((String, String) -> Flow>)? = null private val measurementsFlow = MutableStateFlow>(emptyList()) private fun publishMeasurements() { @@ -177,11 +186,21 @@ class FakeExternalMeasurementRepository : ExternalMeasurementRepository { rows.filter { it.profileId == profileId && (provider == null || it.provider == provider) } } - override fun observeMeasurementsByType(profileId: String, measurementType: String): Flow> = measurementsFlow.map { rows -> - rows.filter { it.profileId == profileId && it.measurementType == measurementType } + override fun observeMeasurementsByType( + profileId: String, + measurementType: String, + ): Flow> { + observationRequests += MeasurementObservationRequest(profileId, measurementType) + return observeByTypeOverride?.invoke(profileId, measurementType) + ?: measurementsFlow.map { rows -> + rows.filter { + it.profileId == profileId && it.measurementType == measurementType + } + } } override suspend fun upsertMeasurements(measurements: List) { + upsertFailure?.let { throw it } for (measurement in measurements) { this.measurements.removeAll { it.provider == measurement.provider && diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakePersonalRecordRepository.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakePersonalRecordRepository.kt index 3d77f059b..ad608816d 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakePersonalRecordRepository.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakePersonalRecordRepository.kt @@ -22,6 +22,15 @@ class FakePersonalRecordRepository : PersonalRecordRepository { // Track calls for verification val updateCalls = mutableListOf() + data class GetAllForExerciseRequest( + val exerciseId: String, + val profileId: String, + ) + + val getAllForExerciseRequests = mutableListOf() + var getAllForExerciseFailure: Throwable? = null + var beforeGetAllForExerciseReturn: (suspend (String, String) -> Unit)? = null + data class UpdateCall( val exerciseId: String, val weightPRWeightPerCableKg: Float, @@ -41,6 +50,9 @@ class FakePersonalRecordRepository : PersonalRecordRepository { fun reset() { records.clear() updateCalls.clear() + getAllForExerciseRequests.clear() + getAllForExerciseFailure = null + beforeGetAllForExerciseReturn = null updateRecordsFlow() } @@ -168,9 +180,18 @@ class FakePersonalRecordRepository : PersonalRecordRepository { } .maxByOrNull { it.volume } - override suspend fun getAllPRsForExercise(exerciseId: String, profileId: String): List = records.values - .filter { it.exerciseId == exerciseId && it.profileId == profileId } - .sortedByDescending { it.timestamp } + override suspend fun getAllPRsForExercise( + exerciseId: String, + profileId: String, + ): List { + getAllForExerciseRequests += GetAllForExerciseRequest(exerciseId, profileId) + getAllForExerciseFailure?.let { throw it } + val result = records.values + .filter { it.exerciseId == exerciseId && it.profileId == profileId } + .sortedByDescending { it.timestamp } + beforeGetAllForExerciseReturn?.invoke(exerciseId, profileId) + return result + } override suspend fun updatePRsIfBetter( exerciseId: String, diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakePortalApiClient.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakePortalApiClient.kt index d3c6f52fd..0c49ec2cc 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakePortalApiClient.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakePortalApiClient.kt @@ -70,18 +70,21 @@ open class FakePortalApiClient : var integrationSyncCallCount = 0 var playgroundSimulationCallCount = 0 var lastPushPayload: PortalSyncPayload? = null + val pushPayloads: MutableList = mutableListOf() + var pushResultsQueue: MutableList>? = null var lastPullKnownEntityIds: KnownEntityIds? = null var lastPullDeviceId: String? = null + var lastPullProfileId: String? = null var lastPullCursor: String? = null var lastPullPageSize: Int? = null val pullCallCursors: MutableList = mutableListOf() + val pullCallProfileIds: MutableList = mutableListOf() val pullCallTimestampsMs: MutableList = mutableListOf() var pullTimestampSourceMs: () -> Long = { currentTimeMillis() } var lastIntegrationSyncRequest: IntegrationSyncRequest? = null var lastPlaygroundSimulationRequest: IntegrationPlaygroundSimulationRequest? = null - // Pagination support: list of results to return for successive pull calls - // If set, returns results from this list in order; when exhausted, falls back to pullResult + // A configured queue describes every expected logical call and fails when exhausted. var pullResultsQueue: MutableList>? = null var integrationSyncResultsQueue: MutableList>? = null @@ -98,7 +101,8 @@ open class FakePortalApiClient : override suspend fun pushPortalPayload(payload: PortalSyncPayload): Result { pushCallCount++ lastPushPayload = payload - return pushResult + pushPayloads += payload + return pushResultsQueue?.removeExpectedResult() ?: pushResult } override suspend fun pullPortalPayload( @@ -111,14 +115,14 @@ open class FakePortalApiClient : pullCallCount++ lastPullKnownEntityIds = knownEntityIds lastPullDeviceId = deviceId + lastPullProfileId = profileId + pullCallProfileIds += profileId lastPullCursor = cursor lastPullPageSize = pageSize pullCallCursors += cursor pullCallTimestampsMs += pullTimestampSourceMs() - // Use queue if available, otherwise fallback to pullResult - val result = pullResultsQueue?.removeFirstOrNull() ?: pullResult - return result + return pullResultsQueue?.removeExpectedResult() ?: pullResult } override suspend fun callIntegrationSync(request: IntegrationSyncRequest): Result { @@ -135,3 +139,8 @@ open class FakePortalApiClient : return playgroundSimulationResult } } + +private fun MutableList>.removeExpectedResult(): Result { + check(isNotEmpty()) { "FAKE_PORTAL_RESULT_QUEUE_EXHAUSTED" } + return removeAt(0) +} diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakePortalApiClientTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakePortalApiClientTest.kt new file mode 100644 index 000000000..1db730798 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakePortalApiClientTest.kt @@ -0,0 +1,73 @@ +package com.devil.phoenixproject.testutil + +import com.devil.phoenixproject.data.sync.KnownEntityIds +import com.devil.phoenixproject.data.sync.PortalSyncPayload +import com.devil.phoenixproject.data.sync.PortalSyncPullResponse +import com.devil.phoenixproject.data.sync.PortalSyncPushResponse +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest + +class FakePortalApiClientTest { + private fun pushResponse(time: String) = + Result.success(PortalSyncPushResponse(syncTime = time)) + + @Test + fun `configured push queue preserves exact history and fails when exhausted`() = runTest { + val fake = FakePortalApiClient() + val first = PortalSyncPayload(deviceId = "first", lastSync = 0) + val second = PortalSyncPayload(deviceId = "second", lastSync = 0) + fake.pushResultsQueue = mutableListOf( + pushResponse("2026-07-11T12:00:00Z"), + pushResponse("2026-07-11T12:00:01Z"), + ) + + assertTrue(fake.pushPortalPayload(first).isSuccess) + assertTrue(fake.pushPortalPayload(second).isSuccess) + + assertEquals(listOf(first, second), fake.pushPayloads) + assertEquals(second, fake.lastPushPayload) + assertEquals(2, fake.pushCallCount) + assertTrue(fake.pushResultsQueue.orEmpty().isEmpty()) + assertFailsWith { + fake.pushPortalPayload(PortalSyncPayload(deviceId = "unexpected", lastSync = 0)) + } + } + + @Test + fun `configured pull queue preserves profile history and fails when exhausted`() = runTest { + val fake = FakePortalApiClient() + fake.pullResultsQueue = mutableListOf( + Result.success(PortalSyncPullResponse(syncTime = 1)), + Result.success(PortalSyncPullResponse(syncTime = 2)), + ) + + listOf("profile-a", "profile-b").forEach { profileId -> + assertTrue( + fake.pullPortalPayload( + knownEntityIds = KnownEntityIds(), + deviceId = "device", + profileId = profileId, + cursor = null, + pageSize = 100, + ).isSuccess, + ) + } + + assertEquals(listOf("profile-a", "profile-b"), fake.pullCallProfileIds) + assertEquals("profile-b", fake.lastPullProfileId) + assertEquals(2, fake.pullCallCount) + assertTrue(fake.pullResultsQueue.orEmpty().isEmpty()) + assertFailsWith { + fake.pullPortalPayload( + KnownEntityIds(), + "device", + "unexpected", + null, + 100, + ) + } + } +} diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakePreferencesManager.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakePreferencesManager.kt index a8591961d..b420d5ffe 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakePreferencesManager.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakePreferencesManager.kt @@ -37,11 +37,11 @@ class FakePreferencesManager : PreferencesManager { _preferencesFlow.value = preferences } - override suspend fun setWeightUnit(unit: WeightUnit) { + suspend fun setWeightUnit(unit: WeightUnit) { _preferencesFlow.value = _preferencesFlow.value.copy(weightUnit = unit) } - override suspend fun setStopAtTop(enabled: Boolean) { + suspend fun setStopAtTop(enabled: Boolean) { _preferencesFlow.value = _preferencesFlow.value.copy(stopAtTop = enabled) } @@ -49,79 +49,81 @@ class FakePreferencesManager : PreferencesManager { _preferencesFlow.value = _preferencesFlow.value.copy(enableVideoPlayback = enabled) } - override suspend fun setBeepsEnabled(enabled: Boolean) { + suspend fun setBeepsEnabled(enabled: Boolean) { _preferencesFlow.value = _preferencesFlow.value.copy(beepsEnabled = enabled) } - override suspend fun setColorScheme(scheme: Int) { + suspend fun setColorScheme(scheme: Int) { _preferencesFlow.value = _preferencesFlow.value.copy(colorScheme = scheme) } - override suspend fun setDiscoModeUnlocked(unlocked: Boolean) { + suspend fun setDiscoModeUnlocked(unlocked: Boolean) { _preferencesFlow.value = _preferencesFlow.value.copy(discoModeUnlocked = unlocked) } - override suspend fun setAudioRepCountEnabled(enabled: Boolean) { + suspend fun setAudioRepCountEnabled(enabled: Boolean) { _preferencesFlow.value = _preferencesFlow.value.copy(audioRepCountEnabled = enabled) } + @Deprecated("Legacy migration read only") override suspend fun getSingleExerciseDefaults(exerciseId: String): SingleExerciseDefaults? = exerciseDefaults[exerciseId] - override suspend fun saveSingleExerciseDefaults(defaults: SingleExerciseDefaults) { + suspend fun saveSingleExerciseDefaults(defaults: SingleExerciseDefaults) { exerciseDefaults[defaults.exerciseId] = defaults } - override suspend fun clearAllSingleExerciseDefaults() { + suspend fun clearAllSingleExerciseDefaults() { exerciseDefaults.clear() } + @Deprecated("Legacy migration read only") override suspend fun getJustLiftDefaults(): JustLiftDefaults = justLiftDefaults - override suspend fun saveJustLiftDefaults(defaults: JustLiftDefaults) { + suspend fun saveJustLiftDefaults(defaults: JustLiftDefaults) { justLiftDefaults = defaults } - override suspend fun clearJustLiftDefaults() { + suspend fun clearJustLiftDefaults() { justLiftDefaults = JustLiftDefaults() } - override suspend fun setSummaryCountdownSeconds(seconds: Int) { + suspend fun setSummaryCountdownSeconds(seconds: Int) { _preferencesFlow.value = _preferencesFlow.value.copy(summaryCountdownSeconds = seconds) } - override suspend fun setAutoStartCountdownSeconds(seconds: Int) { + suspend fun setAutoStartCountdownSeconds(seconds: Int) { _preferencesFlow.value = _preferencesFlow.value.copy(autoStartCountdownSeconds = seconds) } - override suspend fun setRepCountTiming(timing: com.devil.phoenixproject.domain.model.RepCountTiming) { + suspend fun setRepCountTiming(timing: com.devil.phoenixproject.domain.model.RepCountTiming) { _preferencesFlow.value = _preferencesFlow.value.copy(repCountTiming = timing) } - override suspend fun setGamificationEnabled(enabled: Boolean) { + suspend fun setGamificationEnabled(enabled: Boolean) { _preferencesFlow.value = _preferencesFlow.value.copy(gamificationEnabled = enabled) } - override suspend fun setWeightIncrement(increment: Float) { + suspend fun setWeightIncrement(increment: Float) { _preferencesFlow.value = _preferencesFlow.value.copy(weightIncrement = increment) } - override suspend fun setAutoStartRoutine(enabled: Boolean) { + suspend fun setAutoStartRoutine(enabled: Boolean) { _preferencesFlow.value = _preferencesFlow.value.copy(autoStartRoutine = enabled) } - override suspend fun setBodyWeightKg(weightKg: Float) { + suspend fun setBodyWeightKg(weightKg: Float) { _preferencesFlow.value = _preferencesFlow.value.copy(bodyWeightKg = weightKg) } - override suspend fun setCountdownBeepsEnabled(enabled: Boolean) { + suspend fun setCountdownBeepsEnabled(enabled: Boolean) { _preferencesFlow.value = _preferencesFlow.value.copy(countdownBeepsEnabled = enabled) } - override suspend fun setRepSoundEnabled(enabled: Boolean) { + suspend fun setRepSoundEnabled(enabled: Boolean) { _preferencesFlow.value = _preferencesFlow.value.copy(repSoundEnabled = enabled) } - override suspend fun setMotionStartEnabled(enabled: Boolean) { + suspend fun setMotionStartEnabled(enabled: Boolean) { _preferencesFlow.value = _preferencesFlow.value.copy(motionStartEnabled = enabled) } @@ -133,15 +135,15 @@ class FakePreferencesManager : PreferencesManager { _preferencesFlow.value = _preferencesFlow.value.copy(language = language) } - override suspend fun setVoiceStopEnabled(enabled: Boolean) { + suspend fun setVoiceStopEnabled(enabled: Boolean) { _preferencesFlow.value = _preferencesFlow.value.copy(voiceStopEnabled = enabled) } - override suspend fun setSafeWord(word: String?) { + suspend fun setSafeWord(word: String?) { _preferencesFlow.value = _preferencesFlow.value.copy(safeWord = word) } - override suspend fun setSafeWordCalibrated(calibrated: Boolean) { + suspend fun setSafeWordCalibrated(calibrated: Boolean) { _preferencesFlow.value = _preferencesFlow.value.copy(safeWordCalibrated = calibrated) } @@ -149,29 +151,29 @@ class FakePreferencesManager : PreferencesManager { _preferencesFlow.value = _preferencesFlow.value.copy(backupDestination = destination) } - override suspend fun setVelocityLossThreshold(percent: Int) { + suspend fun setVelocityLossThreshold(percent: Int) { _preferencesFlow.value = _preferencesFlow.value.copy( velocityLossThresholdPercent = percent.coerceIn(10, 50), ) } - override suspend fun setAutoEndOnVelocityLoss(enabled: Boolean) { + suspend fun setAutoEndOnVelocityLoss(enabled: Boolean) { _preferencesFlow.value = _preferencesFlow.value.copy(autoEndOnVelocityLoss = enabled) } - override suspend fun setWeightSuggestionsEnabled(enabled: Boolean) { + suspend fun setWeightSuggestionsEnabled(enabled: Boolean) { _preferencesFlow.value = _preferencesFlow.value.copy(weightSuggestionsEnabled = enabled) } - override suspend fun setDefaultScalingBasis(basis: ScalingBasis) { + suspend fun setDefaultScalingBasis(basis: ScalingBasis) { _preferencesFlow.value = _preferencesFlow.value.copy(defaultScalingBasis = basis) } - override suspend fun setDefaultRoutineExerciseUsePercentOfPR(enabled: Boolean) { + suspend fun setDefaultRoutineExerciseUsePercentOfPR(enabled: Boolean) { _preferencesFlow.value = _preferencesFlow.value.copy(defaultRoutineExerciseUsePercentOfPR = enabled) } - override suspend fun setDefaultRoutineExerciseWeightPercentOfPR(percent: Int) { + suspend fun setDefaultRoutineExerciseWeightPercentOfPR(percent: Int) { _preferencesFlow.value = _preferencesFlow.value.copy(defaultRoutineExerciseWeightPercentOfPR = percent.coerceIn(50, 120)) } @@ -181,7 +183,7 @@ class FakePreferencesManager : PreferencesManager { // Issue #611: Verbal encouragement + opt-in vulgar mode + Dominatrix mode + 18+ gate // Cascade invariants mirror SettingsPreferencesManager. - override suspend fun setVerbalEncouragementEnabled(enabled: Boolean) { + suspend fun setVerbalEncouragementEnabled(enabled: Boolean) { _preferencesFlow.value = if (!enabled) { _preferencesFlow.value.copy( verbalEncouragementEnabled = false, @@ -193,7 +195,7 @@ class FakePreferencesManager : PreferencesManager { } } - override suspend fun setVulgarModeEnabled(enabled: Boolean) { + suspend fun setVulgarModeEnabled(enabled: Boolean) { val current = _preferencesFlow.value if (enabled && !current.adultsOnlyConfirmed) return _preferencesFlow.value = if (!enabled) { @@ -203,15 +205,15 @@ class FakePreferencesManager : PreferencesManager { } } - override suspend fun setVulgarTier(tier: VulgarTier) { + suspend fun setVulgarTier(tier: VulgarTier) { _preferencesFlow.value = _preferencesFlow.value.copy(vulgarTier = tier) } - override suspend fun setDominatrixModeUnlocked(unlocked: Boolean) { + suspend fun setDominatrixModeUnlocked(unlocked: Boolean) { _preferencesFlow.value = _preferencesFlow.value.copy(dominatrixModeUnlocked = unlocked) } - override suspend fun setDominatrixModeActive(active: Boolean) { + suspend fun setDominatrixModeActive(active: Boolean) { val current = _preferencesFlow.value if (active && (!current.dominatrixModeUnlocked || !current.vulgarModeEnabled || !current.adultsOnlyConfirmed)) { return @@ -219,7 +221,7 @@ class FakePreferencesManager : PreferencesManager { _preferencesFlow.value = current.copy(dominatrixModeActive = active) } - override suspend fun setAdultsOnlyConfirmed(confirmed: Boolean) { + suspend fun setAdultsOnlyConfirmed(confirmed: Boolean) { _preferencesFlow.value = _preferencesFlow.value.copy(adultsOnlyConfirmed = confirmed) // Issue #611 (PR-followup #613): confirm implies prompted (one-shot flag // becomes irrelevant after confirm; mirror SettingsPreferencesManager). @@ -234,9 +236,9 @@ class FakePreferencesManager : PreferencesManager { // for the 18+ Adults Only modal. Lives outside UserPreferences because the // modal-call site is the only consumer (architecture §3 — follow // DiscoModeUnlockDialog pattern). - override fun isAdultsOnlyPrompted(): Boolean = _adultsOnlyPrompted + fun isAdultsOnlyPrompted(): Boolean = _adultsOnlyPrompted - override fun setAdultsOnlyPrompted(prompted: Boolean) { + fun setAdultsOnlyPrompted(prompted: Boolean) { _adultsOnlyPrompted = prompted } } diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeProfilePreferenceSyncRepository.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeProfilePreferenceSyncRepository.kt new file mode 100644 index 000000000..f84a9a040 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeProfilePreferenceSyncRepository.kt @@ -0,0 +1,50 @@ +package com.devil.phoenixproject.testutil + +import com.devil.phoenixproject.data.sync.CanonicalProfilePreferenceSection +import com.devil.phoenixproject.data.sync.ProfilePreferenceDirtySnapshot +import com.devil.phoenixproject.data.sync.ProfilePreferencePushOutcome +import com.devil.phoenixproject.data.sync.ProfilePreferenceSyncApplyReport +import com.devil.phoenixproject.data.sync.ProfilePreferenceSyncRepository + +internal class FakeProfilePreferenceSyncRepository : ProfilePreferenceSyncRepository { + var dirtySnapshot = ProfilePreferenceDirtySnapshot(emptyList(), emptyList()) + var snapshotCallCount = 0 + var snapshotFailure: Exception? = null + var applyPushFailure: Exception? = null + var applyPullFailure: Exception? = null + var knownProfileIds: Set = setOf("profile-a") + var onApplyPulledSections: (() -> Unit)? = null + var pullApplyCallCount = 0 + val appliedPushOutcomes = mutableListOf>() + val pulledSectionCalls = mutableListOf>() + val appliedPulledSections = mutableListOf>() + + override suspend fun snapshotDirtySections(): ProfilePreferenceDirtySnapshot { + snapshotCallCount++ + snapshotFailure?.let { throw it } + return dirtySnapshot + } + + override suspend fun applyPushOutcomes( + outcomes: List, + ): ProfilePreferenceSyncApplyReport { + applyPushFailure?.let { throw it } + appliedPushOutcomes += outcomes + return ProfilePreferenceSyncApplyReport(applied = outcomes.size) + } + + override suspend fun applyPulledSections( + sections: List, + ): ProfilePreferenceSyncApplyReport { + pullApplyCallCount++ + pulledSectionCalls += sections + applyPullFailure?.let { throw it } + onApplyPulledSections?.invoke() + val known = sections.filter { it.key.localProfileId in knownProfileIds } + appliedPulledSections += known + return ProfilePreferenceSyncApplyReport( + applied = known.size, + ignoredUnknownProfile = sections.size - known.size, + ) + } +} diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeSyncRepository.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeSyncRepository.kt index 2112b794a..c69cfe8c6 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeSyncRepository.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeSyncRepository.kt @@ -265,7 +265,7 @@ class FakeSyncRepository : SyncRepository { // === Atomic Pull Merge === - /** Captured data from atomic merge calls */ + /** Captured data from ordinary SyncRepository merge calls. */ var atomicMergeCallCount = 0 var lastAtomicMergeSessions: List = emptyList() var lastAtomicMergeRoutines: List = emptyList() @@ -276,8 +276,9 @@ class FakeSyncRepository : SyncRepository { var lastAtomicMergeLastSync: Long = 0L var lastAtomicMergeProfileId: String = "" - /** Set to throw an exception to simulate atomic merge failure */ + /** Set to throw an exception before the ordinary repository merge commits. */ var atomicMergeShouldFail: Boolean = false + var onMergeAllPullData: (() -> Unit)? = null override suspend fun mergeAllPullData( sessions: List, @@ -290,9 +291,10 @@ class FakeSyncRepository : SyncRepository { profileId: String, ) { if (atomicMergeShouldFail) { - throw RuntimeException("Simulated atomic merge failure for testing rollback") + throw RuntimeException("Simulated ordinary repository merge failure") } + onMergeAllPullData?.invoke() atomicMergeCallCount++ lastAtomicMergeSessions = sessions lastAtomicMergeRoutines = routines diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeUserProfileRepository.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeUserProfileRepository.kt index 76f646319..f70c61c53 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeUserProfileRepository.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeUserProfileRepository.kt @@ -1,34 +1,174 @@ package com.devil.phoenixproject.testutil +import com.devil.phoenixproject.data.preferences.ProfilePreferencesCodec +import com.devil.phoenixproject.data.preferences.ProfilePreferencesValidator +import com.devil.phoenixproject.data.repository.ActiveProfileContext +import com.devil.phoenixproject.data.repository.ProfileContextUnavailableException +import com.devil.phoenixproject.data.repository.StaleProfileContextException import com.devil.phoenixproject.data.repository.SubscriptionStatus import com.devil.phoenixproject.data.repository.UserProfile import com.devil.phoenixproject.data.repository.UserProfileRepository +import com.devil.phoenixproject.domain.model.CoreProfilePreferences +import com.devil.phoenixproject.domain.model.LedPreferences +import com.devil.phoenixproject.domain.model.ProfileLocalSafetyPreferences +import com.devil.phoenixproject.domain.model.ProfilePreferenceSection +import com.devil.phoenixproject.domain.model.ProfilePreferenceValidity +import com.devil.phoenixproject.domain.model.ProfileSectionMetadata +import com.devil.phoenixproject.domain.model.RackPreferences +import com.devil.phoenixproject.domain.model.UserProfilePreferences +import com.devil.phoenixproject.domain.model.VbtPreferences +import com.devil.phoenixproject.domain.model.WorkoutPreferences import com.devil.phoenixproject.domain.model.currentTimeMillis import com.devil.phoenixproject.domain.model.generateUUID import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock class FakeUserProfileRepository : UserProfileRepository { - private val profiles = mutableMapOf() + private val mutex = Mutex() + private val profiles = linkedMapOf() + private val preferenceFlows = mutableMapOf>() + private val localSafety = mutableMapOf() + private var pendingTransition: PendingTransition? = null + + val pendingLocalCleanupProfileIds = linkedSetOf() + var failBeforeProfileDeletionCommit: Boolean = false + var failLocalCleanupDeletes: Boolean = false + + data class UpdateProfileRequest( + val profileId: String, + val name: String, + val colorIndex: Int, + ) + + data class CreateAndActivateRequest(val name: String, val colorIndex: Int) + + sealed interface PreferenceUpdateRequest { + val profileId: String + + data class Core( + override val profileId: String, + val value: CoreProfilePreferences, + ) : PreferenceUpdateRequest + + data class Rack( + override val profileId: String, + val value: RackPreferences, + ) : PreferenceUpdateRequest + + data class Workout( + override val profileId: String, + val value: WorkoutPreferences, + ) : PreferenceUpdateRequest + + data class Led( + override val profileId: String, + val value: LedPreferences, + ) : PreferenceUpdateRequest + + data class Vbt( + override val profileId: String, + val value: VbtPreferences, + ) : PreferenceUpdateRequest + + data class LocalSafety( + override val profileId: String, + val value: ProfileLocalSafetyPreferences, + ) : PreferenceUpdateRequest + } + + val updateProfileRequests = mutableListOf() + val deleteActiveProfileRequests = mutableListOf() + val preferenceUpdateRequests = mutableListOf() + var updateProfileFailure: Throwable? = null + var deleteProfileFailure: Throwable? = null + var deleteActiveProfileResultOverride: Boolean? = null + var beforeUpdateProfileMutation: (suspend (UpdateProfileRequest) -> Unit)? = null + var beforeDeleteActiveProfileMutation: (suspend (String) -> Unit)? = null + var beforePreferenceUpdate: (suspend (PreferenceUpdateRequest) -> Unit)? = null + var updateCoreFailure: Throwable? = null + var updateRackFailure: Throwable? = null + var updateWorkoutFailure: Throwable? = null + var updateLedFailure: Throwable? = null + var updateVbtFailure: Throwable? = null + var updateLocalSafetyFailure: Throwable? = null + + val setActiveProfileRequests = mutableListOf() + val createAndActivateRequests = mutableListOf() + var reconcileActiveProfileContextRequests: Int = 0 + + var setActiveProfileFailure: Throwable? = null + var createAndActivateProfileFailure: Throwable? = null + var reconcileActiveProfileContextFailure: Throwable? = null + + var beforeSetActiveProfile: (suspend (String) -> Unit)? = null + var beforeCreateAndActivateProfile: (suspend (String, Int) -> Unit)? = null + var beforeReconcileActiveProfileContext: (suspend () -> Unit)? = null + + fun resetRootProfileOperationControls() { + setActiveProfileRequests.clear() + createAndActivateRequests.clear() + reconcileActiveProfileContextRequests = 0 + setActiveProfileFailure = null + createAndActivateProfileFailure = null + reconcileActiveProfileContextFailure = null + beforeSetActiveProfile = null + beforeCreateAndActivateProfile = null + beforeReconcileActiveProfileContext = null + } + private val _activeProfile = MutableStateFlow(null) override val activeProfile: StateFlow = _activeProfile.asStateFlow() private val _allProfiles = MutableStateFlow>(emptyList()) override val allProfiles: StateFlow> = _allProfiles.asStateFlow() - private fun updateFlows() { - _allProfiles.value = profiles.values.toList() - _activeProfile.value = profiles.values.firstOrNull { it.isActive } + private val _activeProfileContext = MutableStateFlow( + ActiveProfileContext.Switching(null), + ) + override val activeProfileContext: StateFlow = + _activeProfileContext.asStateFlow() + + override fun observePreferences(profileId: String): Flow = + preferenceFlows[profileId]?.asStateFlow() ?: flow { + error("Unknown profile preferences: $profileId") + } + + fun seedReadyProfileForTest( + profileId: String, + name: String = profileId, + colorIndex: Int = 0, + ): UserProfile { + seedProfileWithDefaultPreferences(profileId, name, colorIndex) + emitReadyForTest(profileId) + return profiles.getValue(profileId) + } + + fun emitSwitchingForTest(targetProfileId: String?) { + _activeProfileContext.value = ActiveProfileContext.Switching(targetProfileId) + } + + fun emitReadyForTest(profileId: String) { + require(profiles.containsKey(profileId)) { "Unknown profile: $profileId" } + setActiveIdentityLocked(profileId) + publishReady(profileId) } fun setActiveProfileForTest( - id: String = "default", + id: String = DEFAULT_PROFILE_ID, subscriptionStatus: SubscriptionStatus = SubscriptionStatus.FREE, supabaseUserId: String? = null, ) { + val updatedProfiles = profiles.mapValues { (profileId, profile) -> + profile.copy(isActive = profileId == id) + } + profiles.clear() + profiles.putAll(updatedProfiles) profiles[id] = UserProfile( id = id, name = "Default", @@ -38,94 +178,524 @@ class FakeUserProfileRepository : UserProfileRepository { supabaseUserId = supabaseUserId, subscriptionStatus = subscriptionStatus, ) - updateFlows() + ensurePreferenceFlow(id) + updateIdentityFlows() + publishReady(id) } - override suspend fun createProfile(name: String, colorIndex: Int): UserProfile { - val profile = UserProfile( - id = generateUUID(), - name = name, - colorIndex = colorIndex, - createdAt = currentTimeMillis(), - isActive = false, - ) - profiles[profile.id] = profile - updateFlows() - return profile + override suspend fun createProfile(name: String, colorIndex: Int): UserProfile = + mutex.withLock { + createProfileLocked(name, colorIndex) + } + + override suspend fun createAndActivateProfile( + name: String, + colorIndex: Int, + ): UserProfile { + val request = CreateAndActivateRequest(name, colorIndex) + createAndActivateRequests += request + beforeCreateAndActivateProfile?.invoke(name, colorIndex) + createAndActivateProfileFailure?.let { throw it } + return mutex.withLock { + val previous = _activeProfileContext.value as? ActiveProfileContext.Ready + ?: throw ProfileContextUnavailableException() + val trimmedName = name.trim() + require(trimmedName.isNotEmpty()) { "Profile name must not be blank" } + val profileId = generateUUID() + _activeProfileContext.value = ActiveProfileContext.Switching(profileId) + pendingTransition = PendingTransition(previous.profile.id, profileId) + val profile = createProfileLocked(trimmedName, colorIndex, profileId) + setActiveIdentityLocked(profile.id) + publishReady(profile.id) + pendingTransition = null + requireNotNull(activeProfile.value) + } } override suspend fun updateProfile(id: String, name: String, colorIndex: Int) { - profiles[id]?.let { existing -> - profiles[id] = existing.copy(name = name, colorIndex = colorIndex) - updateFlows() - } - } - - override suspend fun deleteProfile(id: String): Boolean { - if (id == "default") return false - val wasRemoved = profiles.remove(id) != null - if (_activeProfile.value?.id == id) { - profiles["default"] = profiles["default"]?.copy(isActive = true) ?: UserProfile( - id = "default", - name = "Default", - colorIndex = 0, - createdAt = currentTimeMillis(), - isActive = true, - ) + val request = UpdateProfileRequest(id, name, colorIndex) + updateProfileRequests += request + updateProfileFailure?.let { throw it } + beforeUpdateProfileMutation?.invoke(request) + mutex.withLock { + val trimmedName = name.trim() + require(trimmedName.isNotEmpty()) { "Profile name must not be blank" } + profiles[id]?.let { existing -> + profiles[id] = existing.copy(name = trimmedName, colorIndex = colorIndex) + updateIdentityFlows() + if ((activeProfileContext.value as? ActiveProfileContext.Ready)?.profile?.id == id) { + publishReady(id) + } + } + } + } + + override suspend fun deleteActiveProfile(expectedProfileId: String): Boolean { + deleteActiveProfileRequests += expectedProfileId + deleteProfileFailure?.let { throw it } + beforeDeleteActiveProfileMutation?.invoke(expectedProfileId) + deleteActiveProfileResultOverride?.let { return it } + return mutex.withLock { + val ready = _activeProfileContext.value as? ActiveProfileContext.Ready + ?: throw ProfileContextUnavailableException() + if (ready.profile.id != expectedProfileId) { + throw StaleProfileContextException(expectedProfileId, ready.profile.id) + } + deleteProfileLocked(expectedProfileId, requireActive = true) } - updateFlows() - return wasRemoved + } + + override suspend fun deleteProfile(id: String): Boolean = mutex.withLock { + deleteProfileLocked(id, requireActive = false) + } + + private fun deleteProfileLocked(id: String, requireActive: Boolean): Boolean { + if (id == DEFAULT_PROFILE_ID) return false + val previous = _activeProfileContext.value as? ActiveProfileContext.Ready + ?: throw ProfileContextUnavailableException() + if (requireActive && previous.profile.id != id) { + throw StaleProfileContextException(id, previous.profile.id) + } + profiles[id] ?: return false + val wasActive = previous.profile.id == id + val targetProfileId = if (requireActive || wasActive) DEFAULT_PROFILE_ID else previous.profile.id + require(profiles.containsKey(targetProfileId)) { "Profile deletion target missing: $targetProfileId" } + if (wasActive) { + _activeProfileContext.value = ActiveProfileContext.Switching(targetProfileId) + } + + val profileSnapshot = profiles.toMap() + val preferenceSnapshot = preferenceFlows.toMap() + val pendingCleanupSnapshot = pendingLocalCleanupProfileIds.toSet() + try { + pendingLocalCleanupProfileIds += id + profiles.remove(id) + preferenceFlows.remove(id) + if (wasActive) { + setActiveIdentityMapLocked(targetProfileId) + } + if (failBeforeProfileDeletionCommit) { + failBeforeProfileDeletionCommit = false + error("injected profile deletion failure") + } + } catch (failure: Throwable) { + profiles.clear() + profiles.putAll(profileSnapshot) + preferenceFlows.clear() + preferenceFlows.putAll(preferenceSnapshot) + pendingLocalCleanupProfileIds.clear() + pendingLocalCleanupProfileIds.addAll(pendingCleanupSnapshot) + _activeProfileContext.value = previous + throw failure + } + + updateIdentityFlows() + publishReady(targetProfileId) + retryPendingLocalCleanupLocked(id) + return true } override suspend fun setActiveProfile(id: String) { - val updatedProfiles = profiles.mapValues { (key, profile) -> - profile.copy(isActive = key == id) + setActiveProfileRequests += id + beforeSetActiveProfile?.invoke(id) + setActiveProfileFailure?.let { throw it } + mutex.withLock { + require(profiles.containsKey(id)) { "Unknown profile: $id" } + val previous = _activeProfileContext.value as? ActiveProfileContext.Ready + ?: throw ProfileContextUnavailableException() + _activeProfileContext.value = ActiveProfileContext.Switching(id) + pendingTransition = PendingTransition(previous.profile.id, null) + setActiveIdentityLocked(id) + publishReady(id) + pendingTransition = null } - profiles.clear() - profiles.putAll(updatedProfiles) - updateFlows() } override suspend fun refreshProfiles() { - updateFlows() + mutex.withLock { + updateIdentityFlows() + val readyId = (activeProfileContext.value as? ActiveProfileContext.Ready)?.profile?.id + if (readyId != null && activeProfile.value?.id == readyId) publishReady(readyId) + } } override suspend fun ensureDefaultProfile() { - if (!profiles.containsKey("default")) { - profiles["default"] = UserProfile( - id = "default", - name = "Default", - colorIndex = 0, - createdAt = currentTimeMillis(), - isActive = true, + mutex.withLock { + if (!profiles.containsKey(DEFAULT_PROFILE_ID)) { + profiles[DEFAULT_PROFILE_ID] = UserProfile( + id = DEFAULT_PROFILE_ID, + name = "Default", + colorIndex = 0, + createdAt = currentTimeMillis(), + isActive = profiles.values.none { it.isActive }, + ) + ensurePreferenceFlow(DEFAULT_PROFILE_ID, legacyMigrationVersion = 0) + } + updateIdentityFlows() + } + } + + override suspend fun updateCore(profileId: String, value: CoreProfilePreferences) { + val request = PreferenceUpdateRequest.Core(profileId, value) + preferenceUpdateRequests += request + beforePreferenceUpdate?.invoke(request) + updateCoreFailure?.let { throw it } + require(ProfilePreferencesValidator.core(value).isEmpty()) + mutateActiveProfile(profileId) { current, now -> + current.copy( + core = current.core.copy( + value = value, + validity = ProfilePreferenceValidity.Valid, + metadata = current.core.metadata.advanced(now), + ), ) } - updateFlows() } - override suspend fun linkToSupabase(profileId: String, supabaseUserId: String) { - profiles[profileId]?.let { profile -> - profiles[profileId] = profile.copy( - supabaseUserId = supabaseUserId, - lastAuthAt = currentTimeMillis(), + override suspend fun mutateCore( + profileId: String, + transform: (CoreProfilePreferences) -> CoreProfilePreferences, + ) { + mutateActiveProfile(profileId) { current, now -> + val value = transform(current.core.value) + require(ProfilePreferencesValidator.core(value).isEmpty()) + current.copy( + core = current.core.copy( + value = value, + validity = ProfilePreferenceValidity.Valid, + metadata = current.core.metadata.advanced(now), + ), + ) + } + } + + override suspend fun updateRack(profileId: String, value: RackPreferences) { + val request = PreferenceUpdateRequest.Rack(profileId, value) + preferenceUpdateRequests += request + beforePreferenceUpdate?.invoke(request) + updateRackFailure?.let { throw it } + require(ProfilePreferencesValidator.rack(value).isEmpty()) + mutateActiveProfile(profileId) { current, now -> + current.copy( + rack = current.rack.copy( + value = value, + raw = ProfilePreferencesCodec.encodeRack(value), + validity = ProfilePreferenceValidity.Valid, + metadata = current.rack.metadata.advanced(now), + ), + ) + } + } + + override suspend fun updateWorkout(profileId: String, value: WorkoutPreferences) { + val request = PreferenceUpdateRequest.Workout(profileId, value) + preferenceUpdateRequests += request + beforePreferenceUpdate?.invoke(request) + updateWorkoutFailure?.let { throw it } + require(ProfilePreferencesValidator.workout(value).isEmpty()) + mutateActiveProfile(profileId) { current, now -> + current.copy( + workout = current.workout.copy( + value = value, + raw = ProfilePreferencesCodec.encodeWorkout(value), + validity = ProfilePreferenceValidity.Valid, + metadata = current.workout.metadata.advanced(now), + ), + ) + } + } + + override suspend fun updateLed(profileId: String, value: LedPreferences) { + val request = PreferenceUpdateRequest.Led(profileId, value) + preferenceUpdateRequests += request + beforePreferenceUpdate?.invoke(request) + updateLedFailure?.let { throw it } + require(ProfilePreferencesValidator.led(value).isEmpty()) + mutateActiveProfile(profileId) { current, now -> + current.copy( + led = current.led.copy( + value = value, + raw = ProfilePreferencesCodec.encodeLed(value), + validity = ProfilePreferenceValidity.Valid, + metadata = current.led.metadata.advanced(now), + ), ) - updateFlows() } } - override suspend fun updateSubscriptionStatus(profileId: String, status: SubscriptionStatus, expiresAt: Long?) { - profiles[profileId]?.let { profile -> - profiles[profileId] = profile.copy( - subscriptionStatus = status, - subscriptionExpiresAt = expiresAt, + override suspend fun updateVbt(profileId: String, value: VbtPreferences) { + val request = PreferenceUpdateRequest.Vbt(profileId, value) + preferenceUpdateRequests += request + beforePreferenceUpdate?.invoke(request) + updateVbtFailure?.let { throw it } + require(ProfilePreferencesValidator.vbt(value).isEmpty()) + mutateActiveProfile(profileId) { current, now -> + current.copy( + vbt = current.vbt.copy( + value = value, + raw = ProfilePreferencesCodec.encodeVbt(value), + validity = ProfilePreferenceValidity.Valid, + metadata = current.vbt.metadata.advanced(now), + ), ) - updateFlows() } } - override suspend fun getProfileBySupabaseId(supabaseUserId: String): UserProfile? = profiles.values.firstOrNull { - it.supabaseUserId == supabaseUserId + override suspend fun updateLocalSafety( + profileId: String, + value: ProfileLocalSafetyPreferences, + ) { + val request = PreferenceUpdateRequest.LocalSafety(profileId, value) + preferenceUpdateRequests += request + beforePreferenceUpdate?.invoke(request) + updateLocalSafetyFailure?.let { throw it } + mutex.withLock { + requireActiveProfileId(profileId) + localSafety[profileId] = value + publishReady(profileId) + } + } + + override suspend fun retryPendingLocalCleanup(profileId: String?) { + mutex.withLock { + retryPendingLocalCleanupLocked(profileId) + } + } + + private fun retryPendingLocalCleanupLocked(profileId: String?) { + if (failLocalCleanupDeletes) return + pendingLocalCleanupProfileIds + .filter { profileId == null || it == profileId } + .forEach { pendingId -> + localSafety.remove(pendingId) + pendingLocalCleanupProfileIds.remove(pendingId) + } + } + + override suspend fun recoverPendingProfileTransitionForStartup() { + mutex.withLock { + _activeProfileContext.value = ActiveProfileContext.Switching(null) + recoverPendingTransitionLocked() + _activeProfileContext.value = ActiveProfileContext.Switching(activeProfile.value?.id) + } + } + + override suspend fun reconcileActiveProfileContext() { + reconcileActiveProfileContextRequests += 1 + beforeReconcileActiveProfileContext?.invoke() + reconcileActiveProfileContextFailure?.let { throw it } + mutex.withLock { + _activeProfileContext.value = ActiveProfileContext.Switching( + pendingTransition?.priorProfileId, + ) + recoverPendingTransitionLocked() + activeProfile.value?.id?.let(::publishReady) + } + } + + override suspend fun linkToSupabase(profileId: String, supabaseUserId: String) { + mutex.withLock { + profiles[profileId]?.let { profile -> + profiles[profileId] = profile.copy( + supabaseUserId = supabaseUserId, + lastAuthAt = currentTimeMillis(), + ) + updateIdentityFlows() + if ((activeProfileContext.value as? ActiveProfileContext.Ready)?.profile?.id == profileId) { + publishReady(profileId) + } + } + } + } + + override suspend fun updateSubscriptionStatus( + profileId: String, + status: SubscriptionStatus, + expiresAt: Long?, + ) { + mutex.withLock { + profiles[profileId]?.let { profile -> + profiles[profileId] = profile.copy( + subscriptionStatus = status, + subscriptionExpiresAt = expiresAt, + ) + updateIdentityFlows() + if ((activeProfileContext.value as? ActiveProfileContext.Ready)?.profile?.id == profileId) { + publishReady(profileId) + } + } + } + } + + override suspend fun getProfileBySupabaseId(supabaseUserId: String): UserProfile? = + profiles.values.firstOrNull { it.supabaseUserId == supabaseUserId } + + override fun getActiveProfileSubscriptionStatus(): Flow = + flowOf(activeProfile.value?.subscriptionStatus ?: SubscriptionStatus.FREE) + + private fun createProfileLocked( + name: String, + colorIndex: Int, + id: String = generateUUID(), + ): UserProfile = seedProfileWithDefaultPreferences( + profileId = id, + name = name, + colorIndex = colorIndex, + ) + + private fun seedProfileWithDefaultPreferences( + profileId: String, + name: String, + colorIndex: Int, + ): UserProfile { + require(profileId.isNotBlank()) { "Profile ID must not be blank" } + val trimmedName = name.trim() + require(trimmedName.isNotEmpty()) { "Profile name must not be blank" } + require(!profiles.containsKey(profileId)) { "Profile already exists: $profileId" } + val profile = UserProfile( + id = profileId, + name = trimmedName, + colorIndex = colorIndex, + createdAt = currentTimeMillis(), + isActive = false, + ) + profiles[profile.id] = profile + ensurePreferenceFlow(profile.id) + localSafety.getOrPut(profile.id) { ProfileLocalSafetyPreferences() } + updateIdentityFlows() + return profile + } + + private fun setActiveIdentityLocked(profileId: String) { + setActiveIdentityMapLocked(profileId) + updateIdentityFlows() + } + + private fun setActiveIdentityMapLocked(profileId: String) { + val updatedProfiles = profiles.mapValues { (id, profile) -> + profile.copy(isActive = id == profileId) + } + profiles.clear() + profiles.putAll(updatedProfiles) + } + + private suspend fun mutateActiveProfile( + profileId: String, + update: (UserProfilePreferences, Long) -> UserProfilePreferences, + ) { + mutex.withLock { + requireActiveProfileId(profileId) + val flow = requirePreferenceFlow(profileId) + flow.value = update(flow.value, currentTimeMillis()) + publishReady(profileId) + } } - override fun getActiveProfileSubscriptionStatus(): Flow = flowOf(activeProfile.value?.subscriptionStatus ?: SubscriptionStatus.FREE) + private fun requireActiveProfileId(expectedProfileId: String) { + val context = activeProfileContext.value as? ActiveProfileContext.Ready + ?: throw ProfileContextUnavailableException() + if (context.profile.id != expectedProfileId) { + throw StaleProfileContextException(expectedProfileId, context.profile.id) + } + } + + private fun recoverPendingTransitionLocked() { + pendingTransition?.let { pending -> + pending.createdProfileId?.let { createdId -> + profiles.remove(createdId) + preferenceFlows.remove(createdId) + localSafety.remove(createdId) + } + if (profiles.containsKey(pending.priorProfileId)) { + setActiveIdentityLocked(pending.priorProfileId) + } else if (profiles.containsKey(DEFAULT_PROFILE_ID)) { + setActiveIdentityLocked(DEFAULT_PROFILE_ID) + } + pendingTransition = null + } + updateIdentityFlows() + } + + private fun updateIdentityFlows() { + _allProfiles.value = profiles.values.toList() + _activeProfile.value = profiles.values.firstOrNull { it.isActive } + } + + private fun publishReady(profileId: String) { + val profile = profiles[profileId] ?: error("Unknown profile: $profileId") + _activeProfileContext.value = ActiveProfileContext.Ready( + profile = profile, + preferences = requirePreferenceFlow(profileId).value, + localSafety = localSafety[profileId] ?: ProfileLocalSafetyPreferences(), + ) + } + + private fun ensurePreferenceFlow( + profileId: String, + legacyMigrationVersion: Int = 1, + ): MutableStateFlow = + preferenceFlows.getOrPut(profileId) { + MutableStateFlow(defaultPreferences(profileId, legacyMigrationVersion)) + } + + private fun requirePreferenceFlow( + profileId: String, + ): MutableStateFlow = preferenceFlows[profileId] + ?: error("Unknown profile preferences: $profileId") + + private fun defaultPreferences( + profileId: String, + legacyMigrationVersion: Int, + ): UserProfilePreferences { + val metadata = ProfileSectionMetadata(0L, 0L, 0L, true) + return UserProfilePreferences( + profileId = profileId, + schemaVersion = 1, + legacyMigrationVersion = legacyMigrationVersion, + core = ProfilePreferenceSection( + CoreProfilePreferences(), + validity = ProfilePreferenceValidity.Valid, + metadata = metadata, + ), + rack = ProfilePreferenceSection( + RackPreferences(), + ProfilePreferencesCodec.encodeRack(RackPreferences()), + ProfilePreferenceValidity.Valid, + metadata, + ), + workout = ProfilePreferenceSection( + WorkoutPreferences(), + ProfilePreferencesCodec.encodeWorkout(WorkoutPreferences()), + ProfilePreferenceValidity.Valid, + metadata, + ), + led = ProfilePreferenceSection( + LedPreferences(), + ProfilePreferencesCodec.encodeLed(LedPreferences()), + ProfilePreferenceValidity.Valid, + metadata, + ), + vbt = ProfilePreferenceSection( + VbtPreferences(), + ProfilePreferencesCodec.encodeVbt(VbtPreferences()), + ProfilePreferenceValidity.Valid, + metadata, + ), + ) + } + + private fun ProfileSectionMetadata.advanced(now: Long) = copy( + updatedAt = now, + localGeneration = localGeneration + 1, + dirty = true, + ) + + private data class PendingTransition( + val priorProfileId: String, + val createdProfileId: String?, + ) + + private companion object { + const val DEFAULT_PROFILE_ID = "default" + } } diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeVelocityOneRepMaxRepository.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeVelocityOneRepMaxRepository.kt index a2384a911..4df102964 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeVelocityOneRepMaxRepository.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeVelocityOneRepMaxRepository.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.flowOf */ class FakeVelocityOneRepMaxRepository : VelocityOneRepMaxRepository { var latestPassing: VelocityOneRepMaxEntity? = null + var latestPassingFailure: Throwable? = null var allPassing: List = emptyList() /** @@ -22,8 +23,15 @@ class FakeVelocityOneRepMaxRepository : VelocityOneRepMaxRepository { */ var hasEstimatesResult: Boolean = false - override suspend fun getLatestPassing(exerciseId: String, profileId: String): VelocityOneRepMaxEntity? = - latestPassing?.takeIf { it.exerciseId == exerciseId && it.profileId == profileId } + override suspend fun getLatestPassing( + exerciseId: String, + profileId: String, + ): VelocityOneRepMaxEntity? { + latestPassingFailure?.let { throw it } + return latestPassing?.takeIf { + it.exerciseId == exerciseId && it.profileId == profileId + } + } override suspend fun getAllPassing(profileId: String): List = allPassing.filter { it.profileId == profileId } override fun getHistory(exerciseId: String, profileId: String): Flow> = flowOf(emptyList()) diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeWorkoutRepository.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeWorkoutRepository.kt index 89673be29..1e8ae43be 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeWorkoutRepository.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeWorkoutRepository.kt @@ -2,6 +2,7 @@ package com.devil.phoenixproject.testutil import com.devil.phoenixproject.data.repository.PersonalRecordEntity import com.devil.phoenixproject.data.repository.PhaseStatisticsData +import com.devil.phoenixproject.data.repository.MAX_RECENT_EXERCISE_SESSIONS import com.devil.phoenixproject.data.repository.WorkoutRepository import com.devil.phoenixproject.domain.model.HeuristicStatistics import com.devil.phoenixproject.domain.model.Routine @@ -19,6 +20,12 @@ import kotlinx.coroutines.flow.map */ class FakeWorkoutRepository : WorkoutRepository { + data class RecentCompletedRequest( + val exerciseId: String, + val profileId: String, + val limit: Int, + ) + private val sessions = mutableMapOf() private val routines = mutableMapOf() private val metrics = mutableMapOf>() @@ -30,6 +37,10 @@ class FakeWorkoutRepository : WorkoutRepository { private val _personalRecordsFlow = MutableStateFlow>(emptyList()) private val _phaseStatisticsFlow = MutableStateFlow>(emptyList()) + val recentCompletedRequests = mutableListOf() + var recentCompletedFailure: Throwable? = null + var mostRecentCompletedExerciseFailure: Throwable? = null + // Test control methods fun addSession(session: WorkoutSession) { sessions[session.id] = session @@ -61,6 +72,9 @@ class FakeWorkoutRepository : WorkoutRepository { metrics.clear() personalRecords.clear() phaseStatistics.clear() + recentCompletedRequests.clear() + recentCompletedFailure = null + mostRecentCompletedExerciseFailure = null updateSessionsFlow() updateRoutinesFlow() updatePersonalRecordsFlow() @@ -103,6 +117,44 @@ class FakeWorkoutRepository : WorkoutRepository { } } + override suspend fun getRecentCompletedSessionsForExercise( + exerciseId: String, + profileId: String, + limit: Int, + ): List { + require(exerciseId.isNotBlank()) + require(profileId.isNotBlank()) + require(limit in 1..MAX_RECENT_EXERCISE_SESSIONS) + recentCompletedRequests += RecentCompletedRequest(exerciseId, profileId, limit) + recentCompletedFailure?.let { throw it } + return sessions.values + .asSequence() + .filter { it.profileId == profileId && it.exerciseId == exerciseId } + .filter { it.workingReps > 0 || it.totalReps > 0 } + .sortedWith( + compareByDescending { it.timestamp } + .thenByDescending { it.id }, + ) + .take(limit) + .toList() + } + + override suspend fun getMostRecentCompletedExerciseId(profileId: String): String? { + require(profileId.isNotBlank()) + mostRecentCompletedExerciseFailure?.let { throw it } + return sessions.values + .asSequence() + .filter { it.profileId == profileId } + .filter { it.workingReps > 0 || it.totalReps > 0 } + .filter { !it.exerciseId.isNullOrBlank() } + .sortedWith( + compareByDescending { it.timestamp } + .thenByDescending { it.id }, + ) + .firstOrNull() + ?.exerciseId + } + override suspend fun getSessionCountForExercise(exerciseId: String, profileId: String): Long = sessions.values.count { it.exerciseId == exerciseId }.toLong() override suspend fun saveSession(session: WorkoutSession) { diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/BackupSerializationTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/BackupSerializationTest.kt index 86a807785..b72a0c38a 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/BackupSerializationTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/BackupSerializationTest.kt @@ -10,6 +10,8 @@ import kotlin.test.assertNull import kotlin.test.assertTrue import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement /** * Integration tests for backup serialization round-trips with v0.9.0 additions. @@ -26,6 +28,7 @@ class BackupSerializationTest { private val json = Json { ignoreUnknownKeys = true encodeDefaults = true + explicitNulls = false } @Test @@ -165,11 +168,11 @@ class BackupSerializationTest { @Test fun equipmentRackItemsAndRoutineDefaultIdsRoundTrip() { val original = BackupData( - version = CURRENT_BACKUP_VERSION, + version = 4, exportedAt = "2026-06-07T12:00:00Z", appVersion = "0.9.0", data = BackupContent( - equipmentRackItems = listOf( + legacyEquipmentRackItems = json.encodeToJsonElement(listOf( RackItem( id = "vest", name = "Weighted vest", @@ -177,7 +180,7 @@ class BackupSerializationTest { weightKg = 10f, behavior = RackItemBehavior.ADDED_RESISTANCE, ), - ), + )), routineExercises = listOf( RoutineExerciseBackup( id = "rex-rack", @@ -198,7 +201,12 @@ class BackupSerializationTest { val jsonString = json.encodeToString(original) val deserialized = json.decodeFromString(jsonString) - assertEquals("vest", deserialized.data.equipmentRackItems.single().id) + assertEquals( + "vest", + json.decodeFromJsonElement>( + requireNotNull(deserialized.data.legacyEquipmentRackItems), + ).single().id, + ) assertEquals(listOf("vest"), deserialized.data.routineExercises.single().defaultRackItemIds) } @@ -277,4 +285,35 @@ class BackupSerializationTest { ) } } + + @Test + fun v5WireDefaultsExposePreferencesAndOmitLegacyRackAndLocalOnlyState() { + val backupData = BackupData( + exportedAt = "2026-07-12T00:00:00Z", + appVersion = "test", + data = BackupContent(), + ) + + val jsonString = json.encodeToString(backupData) + + assertEquals(5, CURRENT_BACKUP_VERSION) + assertTrue(jsonString.contains("\"profilePreferences\"")) + assertFalse(jsonString.contains("equipmentRackItems")) + listOf( + "localGeneration", + "serverRevision", + "dirty", + "safeWord", + "safe_word", + "calibrated", + "adultsOnly", + "adult_confirm", + "adult_prompt", + ).forEach { forbidden -> + assertFalse(jsonString.contains(forbidden, ignoreCase = true), forbidden) + } + assertTrue(backupData.privacy.userFacingSummary.contains("profile training preferences")) + assertTrue(backupData.privacy.userFacingSummary.contains("voice", ignoreCase = true)) + assertTrue(backupData.privacy.userFacingSummary.contains("adult", ignoreCase = true)) + } } diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/KmpUtilsTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/KmpUtilsTest.kt index 9bd3f07d4..b3e1b7b43 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/KmpUtilsTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/KmpUtilsTest.kt @@ -2,6 +2,8 @@ package com.devil.phoenixproject.util import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue /** * Tests for KmpUtils.formatFloat, focused on the non-finite guard. @@ -14,6 +16,15 @@ import kotlin.test.assertEquals */ class KmpUtilsTest { + @Test + fun formatTimestamp_compactMonthDay_doesNotFallBackToNumericDate() { + val formatted = KmpUtils.formatTimestamp(1_718_452_800_000L, "MMM d") + + assertTrue(Regex("[A-Z][a-z]{2} \\d{1,2}").matches(formatted), formatted) + assertFalse('/' in formatted, formatted) + assertFalse(',' in formatted, formatted) + } + @Test fun formatFloat_nan_withDecimals_returnsZeroNoThrow() { assertEquals("0.0", KmpUtils.formatFloat(Float.NaN, 1)) diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/RackItemJsonTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/RackItemJsonTest.kt new file mode 100644 index 000000000..6df3e7be4 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/RackItemJsonTest.kt @@ -0,0 +1,33 @@ +package com.devil.phoenixproject.util + +import com.devil.phoenixproject.domain.model.RackItem +import com.devil.phoenixproject.domain.model.RackItemBehavior +import com.devil.phoenixproject.domain.model.RackItemCategory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json + +class RackItemJsonTest { + @Test + fun `rack snapshot encoder round trips typed items with defaults`() { + val item = RackItem( + id = "rack-item", + name = "Weighted Vest", + category = RackItemCategory.WEIGHTED_VEST, + weightKg = 10f, + behavior = RackItemBehavior.ADDED_RESISTANCE, + enabled = true, + sortOrder = 1, + createdAt = 100L, + updatedAt = 200L, + ) + + val encoded = encodeRackItemsJson(listOf(item)) + + assertEquals(listOf(item), Json.decodeFromString>(encoded)) + assertTrue(encoded.contains("\"enabled\":true")) + assertTrue(encoded.contains("\"sortOrder\":1")) + } +} diff --git a/shared/src/iosMain/kotlin/com/devil/phoenixproject/IosAppHost.kt b/shared/src/iosMain/kotlin/com/devil/phoenixproject/IosAppHost.kt index a4cde7b71..4e1021dd3 100644 --- a/shared/src/iosMain/kotlin/com/devil/phoenixproject/IosAppHost.kt +++ b/shared/src/iosMain/kotlin/com/devil/phoenixproject/IosAppHost.kt @@ -3,6 +3,7 @@ package com.devil.phoenixproject import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import co.touchlab.kermit.Logger +import com.devil.phoenixproject.data.migration.MigrationManager import com.devil.phoenixproject.data.repository.ExerciseRepository import com.devil.phoenixproject.data.sync.SyncTriggerManager import com.devil.phoenixproject.presentation.viewmodel.EulaViewModel @@ -16,6 +17,7 @@ private data class IosAppDependencies( val eulaViewModel: EulaViewModel, val exerciseRepository: ExerciseRepository, val syncTriggerManager: SyncTriggerManager, + val migrationManager: MigrationManager, ) @Composable @@ -30,6 +32,7 @@ fun IosAppHost() { eulaViewModel = koin.get(), exerciseRepository = koin.get(), syncTriggerManager = koin.get(), + migrationManager = koin.get(), ) } } @@ -49,6 +52,7 @@ fun IosAppHost() { eulaViewModel = deps.eulaViewModel, exerciseRepository = deps.exerciseRepository, syncTriggerManager = deps.syncTriggerManager, + migrationManager = deps.migrationManager, ) } diff --git a/shared/src/iosMain/kotlin/com/devil/phoenixproject/di/PlatformModule.ios.kt b/shared/src/iosMain/kotlin/com/devil/phoenixproject/di/PlatformModule.ios.kt index 177459a23..acd14bd61 100644 --- a/shared/src/iosMain/kotlin/com/devil/phoenixproject/di/PlatformModule.ios.kt +++ b/shared/src/iosMain/kotlin/com/devil/phoenixproject/di/PlatformModule.ios.kt @@ -89,7 +89,7 @@ actual val platformModule: Module = module { single { IosCsvExporter() } single { IosCsvImporter(get()) } single { IosBackupDestinationResolver() } - single { IosDataBackupManager(get(), get(), get(), get()) } + single { IosDataBackupManager(get(), get(), get(), get(), get()) } single { ConnectivityChecker() } single { IosSafeWordListenerFactory() } single { HealthIntegration() } diff --git a/shared/src/iosMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.ios.kt b/shared/src/iosMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.ios.kt index b4aa72d20..49d1e1c1e 100644 --- a/shared/src/iosMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.ios.kt +++ b/shared/src/iosMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.ios.kt @@ -2,7 +2,8 @@ package com.devil.phoenixproject.util import co.touchlab.kermit.Logger import com.devil.phoenixproject.data.preferences.PreferencesManager -import com.devil.phoenixproject.data.repository.EquipmentRackRepository +import com.devil.phoenixproject.data.repository.ProfilePreferencesRepository +import com.devil.phoenixproject.data.repository.UserProfileRepository import com.devil.phoenixproject.database.VitruvianDatabase import kotlinx.cinterop.BetaInteropApi import kotlinx.cinterop.ExperimentalForeignApi @@ -52,8 +53,9 @@ class IosDataBackupManager( database: VitruvianDatabase, private val preferencesManager: PreferencesManager, private val destinationResolver: BackupDestinationResolver, - equipmentRackRepository: EquipmentRackRepository, -) : BaseDataBackupManager(database, equipmentRackRepository) { + profilePreferencesRepository: ProfilePreferencesRepository, + userProfileRepository: UserProfileRepository, +) : BaseDataBackupManager(database, profilePreferencesRepository, userProfileRepository) { private val fileManager = NSFileManager.defaultManager