diff --git a/AGENTS.md b/AGENTS.md index aef19ad..9ba7998 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,6 +67,13 @@ Display names are not identities. Import and synchronization paths must remain idempotent by stable IDs. +Android exercise editing preserves `exercise_id`: a rename trims and +re-normalizes display metadata in the existing row and must not create a second +exercise. Profile changes are rejected once completed history or an active draft +references the exercise; renaming remains safe. Same-ID catalog reconciliation +updates name metadata in place and rejects different-ID normalized-name +collisions. + ## 5. Desktop implementation The desktop core is C17. @@ -82,17 +89,15 @@ Current primary dependencies: - Ninja; - one active desktop terminal backend. -For the authorized `TUI_NOTCURSES_V1` tranche: +For the completed `TUI_NOTCURSES_V1` infrastructure checkpoint: ```text -legacy backend = ncursesw -target backend = Notcurses +legacy backend = ncursesw (historical only) +active backend = Notcurses ``` -During the migration, ncursesw may remain only as the pre-migration -implementation being replaced. Once `TUI_NOTCURSES_V1=PASS`, active desktop TUI -code and build wiring must use Notcurses and must not retain ncursesw as an -unused permanent compatibility backend. +`TUI_NOTCURSES_V1=PASS`. Active desktop TUI code and build wiring use +Notcurses and do not retain ncursesw as an unused compatibility backend. Business logic, persistence, transport, and rendering remain separated. @@ -159,6 +164,14 @@ It is not the canonical analytics store. The Android UI is driven by exercise metadata, never by exercise-name heuristics. +Android local SQLite schema v4 owns exactly one durable active-session draft. +Every meaningful draft/form mutation is persisted by the repository. Back, +backgrounding and process death never delete the draft. Home offers explicit +resume; whole-draft discard requires confirmation. Final completed-session +insertion and draft deletion are one transaction. Drafts are excluded from +completed history and mobile export. Preserve raw partial form input and use +an explicit, non-destructive migration for future Android schema changes. + ## 7. Synchronization architecture Desktop access to Android uses physical-device discovery with `libudev` and diff --git a/CHANGELOG.md b/CHANGELOG.md index f49e2b7..3f97f04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,15 @@ Detailed implementation chronology remains available in Git history and ### Added +- Android `EXERCISE_EDIT_V1`: visible catalog editing, stable-ID name rename, + explicit invalid/conflict/profile/database results, and profile locking once + completed history or an active draft references the exercise; +- `ANDROID_BANNER_PARITY_V1`: one Android `◆ TRAINLOG ◆` header component + matching the compact Notcurses banner's accent and muted context rhythm; + +- one durable Android active-session draft with Home resume, raw form restore, + confirmed discard and draft-only exercise removal; + - native Kotlin/Compose Android capture client; - Android local exercise, session, continuous-activity, and body persistence; - C17/ncursesw desktop TUI with direct session entry and durable SQLite history; @@ -36,6 +45,14 @@ Detailed implementation chronology remains available in Git history and ### Changed +- same-ID Android ↔ PC catalog reconciliation now updates display-name metadata + in place and rejects a different-ID normalized-name collision, preserving + synchronization identity and preventing renamed duplicates; + +- Android local SQLite v3 -> v4 additive migration for structured active drafts; +- completed-session insertion and draft clearing are atomic; drafts remain + excluded from completed history and frozen mobile export; + - desktop SQLite schema evolved to v5; - schema v5 permits targetless set-session rows for actual-only mobile data; - heterogeneous performed sets are preserved without inventing a uniform target; @@ -51,6 +68,11 @@ Detailed implementation chronology remains available in Git history and ### Fixed +- in-progress Android workout loss when leaving the foreground or recreating + the Activity/process; +- missing selected-exercise recovery preserves raw partial input and reports a + specific warning; draft write/finalization failures return explicit errors; + - stale schema-v4 importer call after desktop schema v5 migration; - stale schema-v4 guard in the PC catalog exporter; - missing `sy` prefix support in the UUID creator; @@ -67,10 +89,14 @@ Current validated baseline: TRAINLOG_FORMAT_V1=FROZEN DESKTOP_SCHEMA_V5=PASS -DESKTOP_TESTS=19/19 PASS +DESKTOP_TESTS=22/22 PASS ANDROID_BUILD=PASS ANDROID_LOCAL_WORKFLOWS=PASS +ANDROID_LOCAL_DATABASE_V4=PASS +ANDROID_HOST_TESTS=8/8 PASS +ANDROID_DEVICE_INSTRUMENTATION=5/5 PASS +ANDROID_SESSION_DRAFT_V1=PASS USB_MTP_DETECTION=PASS MTP_ROUNDTRIP=PASS diff --git a/README.md b/README.md index 3b0e839..15ac034 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Trainlog is a local-first workout and body-tracking system with two user interfaces: - a native Android application optimized for fast data entry during training; -- a C17/ncursesw TUI used for durable history, editing, visualization, +- a C17/Notcurses TUI used for durable history, editing, visualization, statistics, and synchronization. The desktop SQLite database is the canonical long-term history. Android keeps @@ -18,6 +18,10 @@ TRAINLOG_FORMAT_V1=FROZEN DESKTOP_SCHEMA_V5=PASS ANDROID_LOCAL_WORKFLOWS=PASS +ANDROID_LOCAL_DATABASE_V4=PASS +ANDROID_SESSION_DRAFT_V1=PASS +EXERCISE_EDIT_V1=PASS +ANDROID_BANNER_PARITY_V1=PASS VARIABLE_REPETITION_SETS=PASS CONTINUOUS_ACTIVITY_TRACKING=PASS @@ -29,7 +33,7 @@ ANDROID_TRIGGERED_SYNC=PASS ANDROID_SYNC_RECEIPT=PASS BIDIRECTIONAL_SYNC_V1=PASS -DESKTOP_TESTS=21/21 PASS +DESKTOP_TESTS=22/22 PASS ANDROID_BUILD=PASS ``` @@ -100,7 +104,7 @@ Actual repetition sets are stored independently. Compact input supports: ```text android/ native Kotlin/Compose Android client -tui/ C17 ncursesw desktop application and core +tui/ C17 Notcurses desktop application and core docs/ canonical project documentation format/ frozen Trainlog JSON v1 schema material examples/ valid frozen-format examples @@ -123,6 +127,22 @@ git diff --check ## Android build +Android keeps one durable in-progress workout in its local SQLite database. +Home offers **Reprendre la séance en cours** after navigation, app switching, +Activity recreation, process death or force-stop/relaunch. Added exercises and +raw unfinished form text are retained. Removing an exercise affects only the +draft; abandoning the draft requires confirmation. Final save atomically +creates completed history and clears the draft. Drafts never enter mobile +export or desktop synchronization as completed sessions. + +Schema v4 migrates additively from v3, preserving existing capture data. See +[Android behavior](docs/android.md) and [validation](docs/tests.md). + +Exercises can be renamed in place from Android. The `ex_` identity is +unchanged; completed history, an active draft, and synchronization therefore +continue to resolve the same logical exercise. Referenced profiles are locked; +only unreferenced catalog exercises may change their recording/tracking profile. + The local Android SDK is intentionally not committed. Configure it with either `ANDROID_HOME` or `android/local.properties`. @@ -226,5 +246,5 @@ BODY_ANALYTICS_V1=PASS BODY_COMPOSITION_ESTIMATE=PASS BODY_PROPORTION_RATIOS=PASS BODY_SYMMETRY_ANALYTICS=PASS -DESKTOP_TESTS=21/21 PASS +DESKTOP_TESTS=22/22 PASS ``` diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 2985b69..77ee765 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -14,6 +14,8 @@ android { versionCode = 1 versionName = "0.1.0" + testInstrumentationRunner = + "androidx.test.runner.AndroidJUnitRunner" } compileOptions { @@ -24,6 +26,19 @@ android { buildFeatures { compose = true } + + testOptions { + unitTests.isIncludeAndroidResources = true + unitTests.all { + it.systemProperty( + "user.home", + layout.buildDirectory + .get() + .asFile + .absolutePath, + ) + } + } } dependencies { @@ -31,6 +46,7 @@ dependencies { platform("androidx.compose:compose-bom:2026.08.00") implementation(composeBom) + androidTestImplementation(composeBom) implementation( "androidx.activity:activity-compose:1.13.0" @@ -53,4 +69,12 @@ dependencies { debugImplementation( "androidx.compose.ui:ui-tooling" ) + + testImplementation("junit:junit:4.13.2") + testImplementation("androidx.test:core:1.7.0") + testImplementation("org.robolectric:robolectric:4.16.1") + + androidTestImplementation("androidx.test.ext:junit:1.3.0") + androidTestImplementation("androidx.test:runner:1.7.0") + androidTestImplementation("androidx.compose.ui:ui-test-junit4") } diff --git a/android/app/src/androidTest/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftInstrumentedTest.kt b/android/app/src/androidTest/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftInstrumentedTest.kt new file mode 100644 index 0000000..d314fd3 --- /dev/null +++ b/android/app/src/androidTest/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftInstrumentedTest.kt @@ -0,0 +1,118 @@ +package com.labfytools.trainlog.data + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.labfytools.trainlog.model.ActiveSessionDraft +import com.labfytools.trainlog.model.NewExerciseProfile +import com.labfytools.trainlog.model.RecordingMode +import com.labfytools.trainlog.model.SessionDraftForm +import com.labfytools.trainlog.model.SessionExerciseDraft +import com.labfytools.trainlog.model.SessionSetDraft +import com.labfytools.trainlog.model.TrackingMode +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.util.UUID + +@RunWith(AndroidJUnit4::class) +class TrainlogRepositoryDraftInstrumentedTest { + private lateinit var context: Context + private lateinit var databaseName: String + private var repository: TrainlogRepository? = null + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + /* INVARIANT: instrumentation never opens the user's production DB. */ + databaseName = "draft-instrumentation-${UUID.randomUUID()}.db" + } + + @After + fun tearDown() { + repository?.close() + context.deleteDatabase(databaseName) + } + + @Test + fun realAndroidSqliteRestoresRawDraftAfterRepositoryRecreation() { + val first = openRepository() + val created = first.createExercise( + NewExerciseProfile( + name = "Test isolé", + recordingMode = RecordingMode.SETS, + trackingMode = TrackingMode.REPS, + dataFields = 0, + ) + ) as CreateExerciseResult.Created + val expected = ActiveSessionDraft( + exercises = listOf( + SessionExerciseDraft( + exercise = created.exercise, + sets = listOf(3, 4, 5).map { SessionSetDraft(reps = it) }, + ) + ), + form = SessionDraftForm( + selectedExercise = created.exercise, + repsText = "4,5,6,", + ), + ) + assertEquals(ActiveDraftMutationResult.Saved, first.saveActiveSessionDraft(expected)) + first.close() + repository = null + + val restored = openRepository().loadActiveSessionDraft() + assertTrue(restored is ActiveDraftLoadResult.Loaded) + restored as ActiveDraftLoadResult.Loaded + assertEquals(expected.exercises, restored.draft.exercises) + assertEquals("4,5,6,", restored.draft.form.repsText) + } + + @Test + fun isolatedFinalizationCreatesOneExportedSessionAndClearsDraft() { + val repo = openRepository() + val created = repo.createExercise( + NewExerciseProfile( + name = "Finalisation isolée", + recordingMode = RecordingMode.SETS, + trackingMode = TrackingMode.DURATION, + dataFields = 0, + ) + ) as CreateExerciseResult.Created + assertEquals( + ActiveDraftMutationResult.Saved, + repo.saveActiveSessionDraft( + ActiveSessionDraft( + exercises = listOf( + SessionExerciseDraft( + exercise = created.exercise, + sets = listOf( + SessionSetDraft(durationSeconds = 20), + SessionSetDraft(durationSeconds = 35), + ), + ) + ) + ) + ), + ) + assertTrue(repo.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved) + assertEquals(ActiveDraftLoadResult.None, repo.loadActiveSessionDraft()) + assertTrue(repo.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Invalid) + assertEquals(1, repo.listSessions().size) + assertEquals( + 1, + JSONObject(repo.buildMobileExportJson()) + .getJSONArray("sessions") + .length(), + ) + } + + private fun openRepository(): TrainlogRepository = + TrainlogRepository(context, databaseName).also { + repository = it + } +} diff --git a/android/app/src/androidTest/java/com/labfytools/trainlog/ui/SessionDraftUiInstrumentedTest.kt b/android/app/src/androidTest/java/com/labfytools/trainlog/ui/SessionDraftUiInstrumentedTest.kt new file mode 100644 index 0000000..ff9eb56 --- /dev/null +++ b/android/app/src/androidTest/java/com/labfytools/trainlog/ui/SessionDraftUiInstrumentedTest.kt @@ -0,0 +1,215 @@ +package com.labfytools.trainlog.ui + +import android.content.Context +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.labfytools.trainlog.data.ActiveDraftMutationResult +import com.labfytools.trainlog.data.CreateExerciseResult +import com.labfytools.trainlog.data.TrainlogRepository +import com.labfytools.trainlog.model.ActiveSessionDraft +import com.labfytools.trainlog.model.NewExerciseProfile +import com.labfytools.trainlog.model.RecordingMode +import com.labfytools.trainlog.model.SessionDraftForm +import com.labfytools.trainlog.model.SessionExerciseDraft +import com.labfytools.trainlog.model.SessionSetDraft +import com.labfytools.trainlog.model.TrackingMode +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.ExternalResource +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SessionDraftUiInstrumentedTest { + private val context: Context = + ApplicationProvider.getApplicationContext() + + @get:Rule(order = 0) + val isolatedDatabase = + object : ExternalResource() { + override fun before() { + seedDraft() + } + + override fun after() { + context.deleteDatabase( + DRAFT_UI_TEST_DATABASE_NAME + ) + } + } + + @get:Rule(order = 1) + val compose = + createAndroidComposeRule< + DraftUiTestActivity + >() + + @Before + fun confirmIsolatedDatabaseName() { + assertTrue( + DRAFT_UI_TEST_DATABASE_NAME != + "trainlog-android.db" + ) + } + + @After + fun closeAnyTestConnection() { + /* Activity and rule cleanup own their respective connections. */ + } + + @Test + fun resumeRestoresRawFormAfterActivityRecreation() { + compose.onNodeWithText( + "Reprendre la séance en cours" + ).assertIsDisplayed() + .performClick() + compose.onNodeWithText( + "4,5,6," + ).performScrollTo() + .assertIsDisplayed() + + compose.activityRule.scenario.recreate() + + compose.onNodeWithText( + "Reprendre la séance en cours" + ).assertIsDisplayed() + .performClick() + compose.onNodeWithText( + "4,5,6," + ).performScrollTo() + .assertIsDisplayed() + compose.onNodeWithText( + "Retirer Test UI" + ).performScrollTo() + .assertIsDisplayed() + } + + @Test + fun confirmedDiscardRemovesResumeWithoutHistory() { + compose.onNodeWithText( + "Supprimer la séance en cours" + ).performClick() + compose.onNodeWithText( + "Confirmer la suppression" + ).assertIsDisplayed() + compose.onNodeWithText( + "Annuler" + ).performClick() + compose.onNodeWithText( + "Reprendre la séance en cours" + ).assertIsDisplayed() + + compose.onNodeWithText( + "Supprimer la séance en cours" + ).performClick() + compose.onNodeWithText( + "Confirmer la suppression" + ).assertIsDisplayed() + .performClick() + compose.onNodeWithText( + "Reprendre la séance en cours" + ).assertDoesNotExist() + + compose.activityRule.scenario.recreate() + + compose.onNodeWithText( + "Reprendre la séance en cours" + ).assertDoesNotExist() + + TrainlogRepository( + context, + DRAFT_UI_TEST_DATABASE_NAME, + ).useForTest { repository -> + assertTrue(repository.listSessions().isEmpty()) + } + } + + @Test + fun finalizeReturnsHomeWithOneCompletedSessionAndNoResume() { + compose.onNodeWithText( + "Reprendre la séance en cours" + ).performClick() + compose.onNodeWithText( + "Enregistrer la séance" + ).performScrollTo() + .performClick() + compose.onNodeWithText( + "Reprendre la séance en cours" + ).assertDoesNotExist() + + compose.activityRule.scenario.recreate() + + compose.onNodeWithText( + "Reprendre la séance en cours" + ).assertDoesNotExist() + + TrainlogRepository( + context, + DRAFT_UI_TEST_DATABASE_NAME, + ).useForTest { repository -> + assertEquals(1, repository.listSessions().size) + } + } + + private fun seedDraft() { + context.deleteDatabase( + DRAFT_UI_TEST_DATABASE_NAME + ) + TrainlogRepository( + context, + DRAFT_UI_TEST_DATABASE_NAME, + ).useForTest { repository -> + val created = + repository.createExercise( + NewExerciseProfile( + name = "Test UI", + recordingMode = + RecordingMode.SETS, + trackingMode = + TrackingMode.REPS, + dataFields = 0, + ) + ) as CreateExerciseResult.Created + assertEquals( + ActiveDraftMutationResult.Saved, + repository.saveActiveSessionDraft( + ActiveSessionDraft( + exercises = listOf( + SessionExerciseDraft( + exercise = created.exercise, + sets = listOf( + SessionSetDraft(reps = 4), + SessionSetDraft(reps = 5), + SessionSetDraft(reps = 6), + ), + ) + ), + form = SessionDraftForm( + selectedExercise = + created.exercise, + repsText = "4,5,6,", + ), + ) + ), + ) + } + } +} + +private inline fun TrainlogRepository.useForTest( + block: (TrainlogRepository) -> Unit, +) { + try { + block(this) + } finally { + close() + } +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..a681744 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/android/app/src/debug/java/com/labfytools/trainlog/ui/DraftUiTestActivity.kt b/android/app/src/debug/java/com/labfytools/trainlog/ui/DraftUiTestActivity.kt new file mode 100644 index 0000000..63a0142 --- /dev/null +++ b/android/app/src/debug/java/com/labfytools/trainlog/ui/DraftUiTestActivity.kt @@ -0,0 +1,126 @@ +package com.labfytools.trainlog.ui + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +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 com.labfytools.trainlog.data.ActiveDraftLoadResult +import com.labfytools.trainlog.data.ActiveDraftMutationResult +import com.labfytools.trainlog.data.TrainlogRepository +import com.labfytools.trainlog.ui.theme.TrainlogTheme + +const val DRAFT_UI_TEST_DATABASE_NAME = + "trainlog-draft-ui-test.db" + +/** + * Debug-only instrumentation host. It deliberately renders production screens + * without SyncExporter and can only open the fixed isolated test database. + */ +class DraftUiTestActivity : ComponentActivity() { + private lateinit var repository: TrainlogRepository + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + repository = + TrainlogRepository( + applicationContext, + DRAFT_UI_TEST_DATABASE_NAME, + ) + + setContent { + TrainlogTheme { + var sessionVisible by + remember { + mutableStateOf(false) + } + var revision by + remember { + mutableIntStateOf(0) + } + var error by + remember { + mutableStateOf(null) + } + + if (sessionVisible) { + SessionScreen( + repository = repository, + catalogRevision = 0, + onBack = { + revision += 1 + sessionVisible = false + }, + onCreateExercise = {}, + /* CONTRACT: this host never writes shared export data. */ + onSessionSaved = {}, + ) + } else { + val loaded = + remember(revision) { + repository + .loadActiveSessionDraft() + } + HomeScreen( + activeDraft = + (loaded as? + ActiveDraftLoadResult.Loaded) + ?.draft, + draftError = + error + ?: (loaded as? + ActiveDraftLoadResult.Error) + ?.message + ?: (loaded as? + ActiveDraftLoadResult.Loaded) + ?.warning, + onSession = { + when ( + val result = + repository + .startActiveSessionDraft() + ) { + ActiveDraftMutationResult.Saved -> { + error = null + sessionVisible = true + } + + is ActiveDraftMutationResult.Error -> { + error = result.message + } + } + }, + onDiscardDraft = { + when ( + val result = + repository + .discardActiveSessionDraft() + ) { + ActiveDraftMutationResult.Saved -> { + error = null + revision += 1 + } + + is ActiveDraftMutationResult.Error -> { + error = result.message + } + } + }, + onExercise = {}, + onBody = {}, + onHistory = {}, + onSync = {}, + ) + } + } + } + } + + override fun onDestroy() { + repository.close() + super.onDestroy() + } +} diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt index ceaff42..bef6f81 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt @@ -5,12 +5,15 @@ import android.content.Context import android.database.sqlite.SQLiteConstraintException import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteOpenHelper +import com.labfytools.trainlog.model.ActiveSessionDraft import com.labfytools.trainlog.model.BodyObservationDraft import com.labfytools.trainlog.model.BodyObservationSummary import com.labfytools.trainlog.model.ExerciseProfile +import com.labfytools.trainlog.model.ExerciseEditInput import com.labfytools.trainlog.model.NewExerciseProfile import com.labfytools.trainlog.model.RecordingMode import com.labfytools.trainlog.model.SessionDraft +import com.labfytools.trainlog.model.SessionDraftForm import com.labfytools.trainlog.model.SessionExerciseDraft import com.labfytools.trainlog.model.SessionSummary import com.labfytools.trainlog.model.SessionDetail @@ -37,6 +40,17 @@ sealed interface CreateExerciseResult { CreateExerciseResult } +sealed interface EditExerciseResult { + data class Saved( + val exercise: ExerciseProfile, + ) : EditExerciseResult + + data object InvalidNameOrProfile : EditExerciseResult + data object Conflict : EditExerciseResult + data object IncompatibleProfileChange : EditExerciseResult + data object DatabaseError : EditExerciseResult +} + sealed interface PcCatalogImportResult { data class Applied( @@ -77,14 +91,56 @@ sealed interface SaveSessionResult { SaveSessionResult } +sealed interface ActiveDraftLoadResult { + data class Loaded( + val draft: ActiveSessionDraft, + val warning: String? = null, + ) : ActiveDraftLoadResult + + data object None : ActiveDraftLoadResult + + data class Error( + val message: String, + ) : ActiveDraftLoadResult +} + +sealed interface ActiveDraftMutationResult { + data object Saved : ActiveDraftMutationResult + + data class Error( + val message: String, + ) : ActiveDraftMutationResult +} + +sealed interface FinalizeActiveDraftResult { + data class Saved( + val sessionId: String, + ) : FinalizeActiveDraftResult + + data class Invalid( + val message: String, + ) : FinalizeActiveDraftResult + + data class DatabaseError( + val message: String, + ) : FinalizeActiveDraftResult +} + class TrainlogRepository( context: Context, + databaseName: String = + ANDROID_DATABASE_NAME, ) { private val database = TrainlogDatabaseHelper( - context.applicationContext + context.applicationContext, + databaseName, ) + fun close() { + database.close() + } + fun listExercises(): List { val output = mutableListOf() @@ -256,6 +312,286 @@ class TrainlogRepository( } } + /** + * WHY: completed sessions and active drafts snapshot profile fields, but + * keeping referenced catalog profiles immutable prevents a later catalog + * sync/profile edit from appearing to change an established exercise. + * A rename remains safe because all relationships use the unchanged row. + */ + fun canEditExerciseProfile( + exerciseId: String, + ): Boolean { + val db = database.readableDatabase + val rowId = lookupExerciseRowIdOrNull(db, exerciseId) ?: return false + return !exerciseHasReferences(db, rowId) + } + + fun editExercise( + input: ExerciseEditInput, + ): EditExerciseResult { + if (!input.validateProfile()) { + return EditExerciseResult.InvalidNameOrProfile + } + + val name = input.name.trim() + val normalized = normalizeName(name) + if (normalized.isEmpty()) { + return EditExerciseResult.InvalidNameOrProfile + } + + val db = database.writableDatabase + return try { + db.beginTransaction() + val current = findExerciseRow(db, "exercise_id = ?", arrayOf(input.exerciseId)) + ?: return EditExerciseResult.DatabaseError + val profileChanged = + current.recordingMode != input.recordingMode || + current.trackingMode != input.trackingMode || + current.dataFields != input.dataFields + if (profileChanged && exerciseHasReferences(db, current.rowId)) { + return EditExerciseResult.IncompatibleProfileChange + } + + val nameOwner = findExerciseRow(db, "normalized_name = ?", arrayOf(normalized)) + if (nameOwner != null && nameOwner.rowId != current.rowId) { + return EditExerciseResult.Conflict + } + + val values = ContentValues().apply { + put("name", name) + put("normalized_name", normalized) + put("recording_mode", input.recordingMode.wireValue) + put("tracking_mode", input.trackingMode.wireValue) + put("data_fields", input.dataFields) + } + if (db.update("exercises", values, "id = ?", arrayOf(current.rowId.toString())) != 1) { + return EditExerciseResult.DatabaseError + } + db.setTransactionSuccessful() + EditExerciseResult.Saved( + ExerciseProfile( + exerciseId = input.exerciseId, + name = name, + normalizedName = normalized, + recordingMode = input.recordingMode, + trackingMode = input.trackingMode, + dataFields = input.dataFields, + ), + ) + } catch (error: SQLiteConstraintException) { + EditExerciseResult.Conflict + } catch (error: Exception) { + EditExerciseResult.DatabaseError + } finally { + if (db.inTransaction()) { + db.endTransaction() + } + } + } + + fun loadActiveSessionDraft(): + ActiveDraftLoadResult = + try { + val restored = + loadActiveSessionDraft( + database.readableDatabase + ) + + if (restored == null) { + ActiveDraftLoadResult.None + } else { + ActiveDraftLoadResult.Loaded( + draft = restored.draft, + warning = restored.warning, + ) + } + } catch (error: Exception) { + ActiveDraftLoadResult.Error( + error.message + ?: "Lecture du brouillon impossible." + ) + } + + fun startActiveSessionDraft(): + ActiveDraftMutationResult { + return when ( + loadActiveSessionDraft() + ) { + is ActiveDraftLoadResult.Loaded -> + ActiveDraftMutationResult.Saved + + is ActiveDraftLoadResult.Error -> + ActiveDraftMutationResult.Error( + "Le brouillon existant ne peut pas être lu." + ) + + ActiveDraftLoadResult.None -> + saveActiveSessionDraft( + ActiveSessionDraft() + ) + } + } + + fun saveActiveSessionDraft( + draft: ActiveSessionDraft, + ): ActiveDraftMutationResult { + if ( + draft.exercises.any { + !validateSessionExercise(it) + } || + draft.exercises + .map { + it.exercise.exerciseId + } + .distinct() + .size != + draft.exercises.size || + listOf( + draft.form.setCountText, + draft.form.repsText, + draft.form.durationText, + draft.form.speedText, + draft.form.distanceText, + ).any { + it.length > MAX_DRAFT_FORM_TEXT_LENGTH + } + ) { + return ActiveDraftMutationResult.Error( + "Brouillon de séance invalide." + ) + } + + var db: SQLiteDatabase? = null + var transactionOpen = false + return try { + db = database.writableDatabase + db.beginTransaction() + transactionOpen = true + persistActiveSessionDraft( + db, + draft, + ) + db.setTransactionSuccessful() + db.endTransaction() + transactionOpen = false + ActiveDraftMutationResult.Saved + } catch (error: Exception) { + if (transactionOpen && db?.inTransaction() == true) { + try { + db.endTransaction() + } catch (endError: Exception) { + error.addSuppressed(endError) + } + } + ActiveDraftMutationResult.Error( + error.message + ?: "Enregistrement du brouillon impossible." + ) + } + } + + fun discardActiveSessionDraft(): + ActiveDraftMutationResult { + return try { + database.writableDatabase.delete( + "active_session_draft", + "id = ?", + arrayOf(ACTIVE_DRAFT_ID.toString()), + ) + ActiveDraftMutationResult.Saved + } catch (error: Exception) { + ActiveDraftMutationResult.Error( + error.message + ?: "Suppression du brouillon impossible." + ) + } + } + + fun finalizeActiveSessionDraft(): + FinalizeActiveDraftResult { + var db: SQLiteDatabase? = null + var transactionOpen = false + return try { + db = database.writableDatabase + db.beginTransaction() + transactionOpen = true + val active = + loadActiveSessionDraft(db) + if (active == null) { + db.endTransaction() + transactionOpen = false + return FinalizeActiveDraftResult.Invalid( + "Aucune séance en cours." + ) + } + + val completed = + SessionDraft( + exercises = + active.draft.exercises, + sessionType = + active.draft.sessionType, + ) + + if ( + completed.exercises.isEmpty() || + completed.exercises.any { + !validateSessionExercise(it) + } + ) { + db.endTransaction() + transactionOpen = false + return FinalizeActiveDraftResult.Invalid( + "La séance en cours est invalide." + ) + } + + val sessionId = + insertCompletedSession( + db, + completed, + ) + + /* + * INVARIANT: completion and draft deletion share this transaction. + * A crash or constraint failure therefore leaves the retryable draft + * and never exposes a completed/draft duplicate pair. + */ + val deleted = + db.delete( + "active_session_draft", + "id = ?", + arrayOf( + ACTIVE_DRAFT_ID + .toString() + ), + ) + + check(deleted == 1) { + "Le brouillon finalisé n'a pas été supprimé." + } + + db.setTransactionSuccessful() + db.endTransaction() + transactionOpen = false + FinalizeActiveDraftResult.Saved( + sessionId + ) + } catch (error: Exception) { + if (transactionOpen && db?.inTransaction() == true) { + try { + db.endTransaction() + } catch (endError: Exception) { + error.addSuppressed(endError) + } + } + FinalizeActiveDraftResult.DatabaseError( + error.message + ?: "Finalisation de la séance impossible." + ) + } + } + fun saveSession( draft: SessionDraft, ): SaveSessionResult { @@ -638,7 +974,40 @@ class TrainlogRepository( ) } - skipped += 1 + /* CONTRACT: a catalog name is mutable metadata. Identity + * reconciliation always prefers exercise_id, so an update + * retains the row used by completed sessions and drafts. */ + val nameOwner = + findExerciseRow( + db, + "normalized_name = ?", + arrayOf(normalized), + ) + if ( + nameOwner != null && + nameOwner.rowId != byId.rowId + ) { + return PcCatalogImportResult.Invalid( + "Conflit de nom catalogue PC pour $name." + ) + } + + val values = ContentValues().apply { + put("name", name.trim()) + put("normalized_name", normalized) + } + if ( + db.update( + "exercises", + values, + "id = ?", + arrayOf(byId.rowId.toString()), + ) != 1 + ) { + return PcCatalogImportResult.DatabaseError + } + + reconciled += 1 continue } @@ -813,6 +1182,42 @@ class TrainlogRepository( } } + private fun lookupExerciseRowIdOrNull( + db: SQLiteDatabase, + exerciseId: String, + ): Long? = + db.query( + "exercises", + arrayOf("id"), + "exercise_id = ?", + arrayOf(exerciseId), + null, + null, + null, + ).use { cursor -> + if (cursor.moveToFirst()) cursor.getLong(0) else null + } + + private fun exerciseHasReferences( + db: SQLiteDatabase, + exerciseRowId: Long, + ): Boolean { + /* INVARIANT: both completed and active-draft records own a catalog-row + * reference. Profile mutation is admitted only while neither exists. */ + return db.rawQuery( + """ + SELECT EXISTS( + SELECT 1 FROM session_exercises WHERE exercise_row_id = ? + UNION ALL + SELECT 1 FROM draft_session_exercises WHERE exercise_row_id = ? + ); + """.trimIndent(), + arrayOf(exerciseRowId.toString(), exerciseRowId.toString()), + ).use { cursor -> + cursor.moveToFirst() && cursor.getInt(0) != 0 + } + } + fun buildMobileExportJson(): String { val root = JSONObject() root.put("format", "trainlog-mobile-export") @@ -834,6 +1239,8 @@ class TrainlogRepository( val db = database.readableDatabase val sessionArray = JSONArray() + /* CONTRACT: only completed `sessions` are part of mobile export v1; + * active draft tables are intentionally outside the frozen artifact. */ db.rawQuery( "SELECT id, session_id, started_at, session_type FROM sessions ORDER BY started_at ASC, id ASC;", null, @@ -1365,6 +1772,514 @@ class TrainlogRepository( ) } + private fun loadActiveSessionDraft( + db: SQLiteDatabase, + ): ActiveDraftRestore? { + val header = + db.rawQuery( + """ + SELECT + d.session_type, + d.set_count_text, + d.reps_text, + d.duration_text, + d.speed_text, + d.distance_text, + d.updated_at, + d.selected_exercise_label, + e.exercise_id, + e.name, + e.normalized_name, + e.recording_mode, + e.tracking_mode, + e.data_fields + FROM active_session_draft AS d + LEFT JOIN exercises AS e + ON e.id = d.selected_exercise_row_id + WHERE d.id = ?; + """.trimIndent(), + arrayOf(ACTIVE_DRAFT_ID.toString()), + ).use { cursor -> + if (!cursor.moveToFirst()) { + null + } else { + val missingSelection = + cursor.isNull(8) && + !cursor.isNull(7) + val selected = + if (cursor.isNull(8)) { + null + } else { + exerciseProfileFromCursor( + cursor, + 8, + ) + } + + ActiveDraftHeader( + sessionType = + SessionType.fromWire( + cursor.getString(0) + ), + form = SessionDraftForm( + selectedExercise = selected, + setCountText = cursor.getString(1), + repsText = cursor.getString(2), + durationText = cursor.getString(3), + speedText = cursor.getString(4), + distanceText = cursor.getString(5), + ), + updatedAt = + cursor.getString(6), + warning = + if (missingSelection) { + "L'exercice en cours de saisie n'existe plus ; " + + "seule la sélection a été annulée. " + + "La saisie partielle et les exercices ajoutés " + + "sont conservés." + } else { + null + }, + ) + } + } ?: return null + + val exercises = + mutableListOf() + + db.rawQuery( + """ + SELECT + de.id, + e.exercise_id, + e.name, + e.normalized_name, + de.recording_mode, + de.tracking_mode, + de.data_fields + FROM draft_session_exercises AS de + JOIN exercises AS e + ON e.id = de.exercise_row_id + WHERE de.draft_id = ? + ORDER BY de.position ASC; + """.trimIndent(), + arrayOf(ACTIVE_DRAFT_ID.toString()), + ).use { cursor -> + while (cursor.moveToNext()) { + val rowId = cursor.getLong(0) + val exercise = + ExerciseProfile( + exerciseId = + cursor.getString(1), + name = cursor.getString(2), + normalizedName = + cursor.getString(3), + recordingMode = + recordingModeFromWire( + cursor.getString(4) + ), + trackingMode = + trackingModeFromWire( + cursor.getString(5) + ), + dataFields = + cursor.getInt(6), + ) + + if ( + exercise.recordingMode == + RecordingMode.CONTINUOUS + ) { + val continuous = + db.query( + "draft_continuous_activity", + arrayOf( + "duration_seconds", + "speed_kmh", + "distance_km", + ), + "draft_exercise_row_id = ?", + arrayOf(rowId.toString()), + null, + null, + null, + ).use { item -> + check(item.moveToFirst()) { + "Activité continue du brouillon manquante." + } + + SessionExerciseDraft( + exercise = exercise, + continuousDurationSeconds = + item.getInt(0), + speedKmh = + if (item.isNull(1)) { + null + } else { + item.getDouble(1) + }, + distanceKm = + if (item.isNull(2)) { + null + } else { + item.getDouble(2) + }, + ) + } + exercises += continuous + } else { + val sets = + mutableListOf() + db.query( + "draft_performed_sets", + arrayOf( + "reps", + "duration_seconds", + ), + "draft_exercise_row_id = ?", + arrayOf(rowId.toString()), + null, + null, + "position ASC", + ).use { setCursor -> + while (setCursor.moveToNext()) { + sets += + SessionSetDraft( + reps = + if (setCursor.isNull(0)) { + 0 + } else { + setCursor.getInt(0) + }, + durationSeconds = + if (setCursor.isNull(1)) { + 0 + } else { + setCursor.getInt(1) + }, + ) + } + } + exercises += + SessionExerciseDraft( + exercise = exercise, + sets = sets, + ) + } + } + } + + return ActiveDraftRestore( + draft = ActiveSessionDraft( + exercises = exercises, + sessionType = header.sessionType, + form = header.form, + updatedAt = header.updatedAt, + ), + warning = header.warning, + ) + } + + private fun persistActiveSessionDraft( + db: SQLiteDatabase, + draft: ActiveSessionDraft, + ) { + val selectedRowId = + draft.form.selectedExercise + ?.let { + lookupExerciseRowId( + db, + it.exerciseId, + ) + } + val now = OffsetDateTime.now().toString() + val values = + ContentValues().apply { + put("session_type", draft.sessionType.wireValue) + if (selectedRowId == null) { + putNull("selected_exercise_row_id") + putNull("selected_exercise_label") + } else { + put("selected_exercise_row_id", selectedRowId) + put( + "selected_exercise_label", + draft.form.selectedExercise.name, + ) + } + put("set_count_text", draft.form.setCountText) + put("reps_text", draft.form.repsText) + put("duration_text", draft.form.durationText) + put("speed_text", draft.form.speedText) + put("distance_text", draft.form.distanceText) + put("updated_at", now) + } + + val updated = + db.update( + "active_session_draft", + values, + "id = ?", + arrayOf(ACTIVE_DRAFT_ID.toString()), + ) + + if (updated == 0) { + values.put("id", ACTIVE_DRAFT_ID) + db.insertOrThrow( + "active_session_draft", + null, + values, + ) + } + + db.delete( + "draft_session_exercises", + "draft_id = ?", + arrayOf(ACTIVE_DRAFT_ID.toString()), + ) + + draft.exercises.forEachIndexed { + index, + exerciseDraft -> + val exerciseRowId = + lookupExerciseRowId( + db, + exerciseDraft.exercise.exerciseId, + ) + val exerciseValues = + ContentValues().apply { + put("draft_id", ACTIVE_DRAFT_ID) + put("exercise_row_id", exerciseRowId) + put("position", index) + put( + "recording_mode", + exerciseDraft.exercise + .recordingMode.wireValue, + ) + put( + "tracking_mode", + exerciseDraft.exercise + .trackingMode.wireValue, + ) + put( + "data_fields", + exerciseDraft.exercise.dataFields, + ) + } + val draftExerciseRowId = + db.insertOrThrow( + "draft_session_exercises", + null, + exerciseValues, + ) + + if ( + exerciseDraft.exercise.recordingMode == + RecordingMode.CONTINUOUS + ) { + val continuousValues = + ContentValues().apply { + put( + "draft_exercise_row_id", + draftExerciseRowId, + ) + put( + "duration_seconds", + exerciseDraft.continuousDurationSeconds, + ) + exerciseDraft.speedKmh?.let { + put("speed_kmh", it) + } + exerciseDraft.distanceKm?.let { + put("distance_km", it) + } + } + db.insertOrThrow( + "draft_continuous_activity", + null, + continuousValues, + ) + } else { + exerciseDraft.sets.forEachIndexed { + setIndex, + set -> + val setValues = + ContentValues().apply { + put( + "draft_exercise_row_id", + draftExerciseRowId, + ) + put("position", setIndex) + if ( + exerciseDraft.exercise.trackingMode == + TrackingMode.REPS + ) { + put("reps", set.reps) + } else { + put( + "duration_seconds", + set.durationSeconds, + ) + } + } + db.insertOrThrow( + "draft_performed_sets", + null, + setValues, + ) + } + } + } + } + + private fun insertCompletedSession( + db: SQLiteDatabase, + draft: SessionDraft, + ): String { + val sessionId = + "se_" + UUID.randomUUID().toString() + /* Preserve the existing Android meaning: started_at is assigned when + * the completed session is saved, not when its draft is first opened. */ + val startedAt = OffsetDateTime.now().toString() + val sessionValues = + ContentValues().apply { + put("session_id", sessionId) + put("started_at", startedAt) + put("session_type", draft.sessionType.wireValue) + } + val sessionRowId = + db.insertOrThrow( + "sessions", + null, + sessionValues, + ) + + draft.exercises.forEachIndexed { + exerciseIndex, + exerciseDraft -> + val exerciseRowId = + lookupExerciseRowId( + db, + exerciseDraft.exercise.exerciseId, + ) + val exerciseValues = + ContentValues().apply { + put("session_row_id", sessionRowId) + put("exercise_row_id", exerciseRowId) + put("position", exerciseIndex) + put( + "recording_mode", + exerciseDraft.exercise.recordingMode.wireValue, + ) + put( + "tracking_mode", + exerciseDraft.exercise.trackingMode.wireValue, + ) + put("data_fields", exerciseDraft.exercise.dataFields) + } + val sessionExerciseRowId = + db.insertOrThrow( + "session_exercises", + null, + exerciseValues, + ) + + if ( + exerciseDraft.exercise.recordingMode == + RecordingMode.CONTINUOUS + ) { + val continuousValues = + ContentValues().apply { + put("session_exercise_row_id", sessionExerciseRowId) + put( + "duration_seconds", + exerciseDraft.continuousDurationSeconds, + ) + exerciseDraft.speedKmh?.let { put("speed_kmh", it) } + exerciseDraft.distanceKm?.let { put("distance_km", it) } + } + db.insertOrThrow( + "continuous_activity", + null, + continuousValues, + ) + } else { + exerciseDraft.sets.forEachIndexed { + setIndex, + set -> + val setValues = + ContentValues().apply { + put("session_exercise_row_id", sessionExerciseRowId) + put("position", setIndex) + if ( + exerciseDraft.exercise.trackingMode == + TrackingMode.REPS + ) { + put("reps", set.reps) + } else { + put("duration_seconds", set.durationSeconds) + } + } + db.insertOrThrow( + "performed_sets", + null, + setValues, + ) + } + } + } + + return sessionId + } + + private fun exerciseProfileFromCursor( + cursor: android.database.Cursor, + offset: Int, + ): ExerciseProfile = + ExerciseProfile( + exerciseId = cursor.getString(offset), + name = cursor.getString(offset + 1), + normalizedName = cursor.getString(offset + 2), + recordingMode = + recordingModeFromWire( + cursor.getString(offset + 3) + ), + trackingMode = + trackingModeFromWire( + cursor.getString(offset + 4) + ), + dataFields = cursor.getInt(offset + 5), + ) + + private fun recordingModeFromWire( + value: String, + ): RecordingMode = + if (value == "continuous") { + RecordingMode.CONTINUOUS + } else { + RecordingMode.SETS + } + + private fun trackingModeFromWire( + value: String, + ): TrackingMode = + if (value == "duration") { + TrackingMode.DURATION + } else { + TrackingMode.REPS + } + + private data class ActiveDraftHeader( + val sessionType: SessionType, + val form: SessionDraftForm, + val updatedAt: String, + val warning: String?, + ) + + private data class ActiveDraftRestore( + val draft: ActiveSessionDraft, + val warning: String?, + ) + private fun validateSessionExercise( draft: SessionExerciseDraft, ): Boolean { @@ -1492,13 +2407,19 @@ private fun ContentValues.putOptionalDouble( } } +private const val ANDROID_DATABASE_NAME = + "trainlog-android.db" +private const val ACTIVE_DRAFT_ID = 1 +private const val MAX_DRAFT_FORM_TEXT_LENGTH = 4096 + private class TrainlogDatabaseHelper( context: Context, + databaseName: String, ) : SQLiteOpenHelper( context, - "trainlog-android.db", + databaseName, null, - 3, + 4, ) { override fun onConfigure( db: SQLiteDatabase, @@ -1516,6 +2437,7 @@ private class TrainlogDatabaseHelper( createExerciseTable(db) createSessionTables(db) createBodyTable(db) + createActiveDraftTables(db) } override fun onUpgrade( @@ -1535,6 +2457,13 @@ private class TrainlogDatabaseHelper( version = 3 } + if (version < 4 && newVersion >= 4) { + /* CONTRACT: v4 is additive. Existing catalog, completed sessions, + * performed values, and body observations remain untouched. */ + createActiveDraftTables(db) + version = 4 + } + if (version != newVersion) { error( "Unsupported Android DB upgrade " + @@ -1754,4 +2683,120 @@ private class TrainlogDatabaseHelper( """.trimIndent() ) } + + private fun createActiveDraftTables( + db: SQLiteDatabase, + ) { + /* WHY: unfinished capture must be durable without entering completed + * history. The singleton check enforces the v1 one-active-draft rule. */ + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS active_session_draft( + id INTEGER PRIMARY KEY + CHECK(id = 1), + session_type TEXT NOT NULL + CHECK( + session_type IN ( + 'training', + 'max_test' + ) + ), + selected_exercise_row_id INTEGER + REFERENCES exercises(id) + ON DELETE SET NULL, + selected_exercise_label TEXT, + set_count_text TEXT NOT NULL, + reps_text TEXT NOT NULL, + duration_text TEXT NOT NULL, + speed_text TEXT NOT NULL, + distance_text TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + """.trimIndent() + ) + + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS draft_session_exercises( + id INTEGER PRIMARY KEY, + draft_id INTEGER NOT NULL + REFERENCES active_session_draft(id) + ON DELETE CASCADE, + exercise_row_id INTEGER NOT NULL + REFERENCES exercises(id) + ON DELETE RESTRICT, + position INTEGER NOT NULL + CHECK(position >= 0), + recording_mode TEXT NOT NULL + CHECK( + recording_mode IN ( + 'sets', + 'continuous' + ) + ), + tracking_mode TEXT NOT NULL + CHECK( + tracking_mode IN ( + 'reps', + 'duration' + ) + ), + data_fields INTEGER NOT NULL + CHECK( + data_fields >= 0 AND + (data_fields & ~3) = 0 + ), + UNIQUE(draft_id, position), + UNIQUE(draft_id, exercise_row_id) + ); + """.trimIndent() + ) + + /* INVARIANT: child cascades terminate at the active-draft singleton; + * neither discard nor child replacement reaches catalog/history rows. */ + + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS draft_performed_sets( + id INTEGER PRIMARY KEY, + draft_exercise_row_id INTEGER NOT NULL + REFERENCES draft_session_exercises(id) + ON DELETE CASCADE, + position INTEGER NOT NULL + CHECK(position >= 0), + reps INTEGER + CHECK(reps >= 0), + duration_seconds INTEGER + CHECK(duration_seconds > 0), + CHECK( + ( + reps IS NOT NULL AND + duration_seconds IS NULL + ) OR ( + reps IS NULL AND + duration_seconds IS NOT NULL + ) + ), + UNIQUE(draft_exercise_row_id, position) + ); + """.trimIndent() + ) + + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS draft_continuous_activity( + id INTEGER PRIMARY KEY, + draft_exercise_row_id INTEGER NOT NULL UNIQUE + REFERENCES draft_session_exercises(id) + ON DELETE CASCADE, + duration_seconds INTEGER NOT NULL + CHECK(duration_seconds > 0), + speed_kmh REAL + CHECK(speed_kmh > 0.0), + distance_km REAL + CHECK(distance_km > 0.0) + ); + """.trimIndent() + ) + } } diff --git a/android/app/src/main/java/com/labfytools/trainlog/model/ExerciseProfile.kt b/android/app/src/main/java/com/labfytools/trainlog/model/ExerciseProfile.kt index 9f5d581..15d8ddd 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/model/ExerciseProfile.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/model/ExerciseProfile.kt @@ -70,3 +70,24 @@ data class NewExerciseProfile( return true } } + +/** + * CONTRACT: an edit addresses the existing stable identity. `name` is + * presentation metadata, not a replacement identity, so callers must never + * create a second exercise merely to rename one. + */ +data class ExerciseEditInput( + val exerciseId: String, + val name: String, + val recordingMode: RecordingMode, + val trackingMode: TrackingMode, + val dataFields: Int, +) { + fun validateProfile(): Boolean = + NewExerciseProfile( + name = name, + recordingMode = recordingMode, + trackingMode = trackingMode, + dataFields = dataFields, + ).validate() +} diff --git a/android/app/src/main/java/com/labfytools/trainlog/model/SessionModels.kt b/android/app/src/main/java/com/labfytools/trainlog/model/SessionModels.kt index 342d68a..8ba99a5 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/model/SessionModels.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/model/SessionModels.kt @@ -36,6 +36,27 @@ data class SessionDraft( val sessionType: SessionType = SessionType.TRAINING, ) +data class SessionDraftForm( + val selectedExercise: ExerciseProfile? = null, + val setCountText: String = "3", + val repsText: String = "3x10", + val durationText: String = "30", + val speedText: String = "", + val distanceText: String = "", +) + +/** + * INVARIANT: this is the one Android-local in-progress workout. It is stored + * separately from [SessionDraft] completion rows so history and sync can never + * mistake unfinished capture for a completed session. + */ +data class ActiveSessionDraft( + val exercises: List = emptyList(), + val sessionType: SessionType = SessionType.TRAINING, + val form: SessionDraftForm = SessionDraftForm(), + val updatedAt: String = "", +) + data class SessionSummary( val sessionId: String, val startedAt: String, diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/ExerciseScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/ExerciseScreen.kt index 4c93d6b..7f5c273 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/ExerciseScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/ExerciseScreen.kt @@ -16,8 +16,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.labfytools.trainlog.data.CreateExerciseResult +import com.labfytools.trainlog.data.EditExerciseResult import com.labfytools.trainlog.data.TrainlogRepository import com.labfytools.trainlog.model.ExerciseDataFields +import com.labfytools.trainlog.model.ExerciseEditInput +import com.labfytools.trainlog.model.ExerciseProfile import com.labfytools.trainlog.model.NewExerciseProfile import com.labfytools.trainlog.model.RecordingMode import com.labfytools.trainlog.model.TrackingMode @@ -70,6 +73,28 @@ fun ExerciseScreen( ) } + var editedExercise by + remember { + mutableStateOf(null) + } + + val profileLocked = + editedExercise?.let { + !repository.canEditExerciseProfile(it.exerciseId) + } ?: false + + fun startEditing(exercise: ExerciseProfile) { + /* WHY: edit state copies catalog metadata for presentation only. The + * repository remains the sole owner of stable identity and SQLite. */ + editedExercise = exercise + name = exercise.name + recordingMode = exercise.recordingMode + trackingMode = exercise.trackingMode + speed = exercise.dataFields and ExerciseDataFields.SPEED_KMH != 0 + distance = exercise.dataFields and ExerciseDataFields.DISTANCE_KM != 0 + message = null + } + TrainlogScreen( subtitle = "E X E R C I C E" ) { @@ -91,7 +116,12 @@ fun ExerciseScreen( ) TrainlogFrame( - title = "NOUVEL EXERCICE" + title = + if (editedExercise == null) { + "NOUVEL EXERCICE" + } else { + "MODIFIER L'EXERCICE" + } ) { TrainlogField( label = "Nom", @@ -110,7 +140,9 @@ fun ExerciseScreen( selected = recordingMode == RecordingMode.SETS, + enabled = !profileLocked, onClick = { + if (profileLocked) return@TrainlogChoice recordingMode = RecordingMode.SETS @@ -125,7 +157,9 @@ fun ExerciseScreen( selected = recordingMode == RecordingMode.CONTINUOUS, + enabled = !profileLocked, onClick = { + if (profileLocked) return@TrainlogChoice recordingMode = RecordingMode.CONTINUOUS @@ -150,7 +184,9 @@ fun ExerciseScreen( selected = trackingMode == TrackingMode.REPS, + enabled = !profileLocked, onClick = { + if (profileLocked) return@TrainlogChoice trackingMode = TrackingMode.REPS @@ -164,7 +200,9 @@ fun ExerciseScreen( selected = trackingMode == TrackingMode.DURATION, + enabled = !profileLocked, onClick = { + if (profileLocked) return@TrainlogChoice trackingMode = TrackingMode.DURATION @@ -184,7 +222,9 @@ fun ExerciseScreen( TrainlogChoice( label = "Vitesse", selected = speed, + enabled = !profileLocked, onClick = { + if (profileLocked) return@TrainlogChoice speed = !speed message = null }, @@ -193,7 +233,9 @@ fun ExerciseScreen( TrainlogChoice( label = "Distance", selected = distance, + enabled = !profileLocked, onClick = { + if (profileLocked) return@TrainlogChoice distance = !distance @@ -240,31 +282,60 @@ fun ExerciseScreen( color = colors.accent, ) + if (profileLocked) { + TrainlogInfo( + text = + "Profil verrouillé : cet exercice est déjà référencé " + + "par une séance terminée ou le brouillon actif. " + + "Le nom reste modifiable.", + color = colors.warning, + ) + } + TrainlogAction( label = - if (inline) { + if (editedExercise != null) { + "Enregistrer les modifications" + } else if (inline) { "Créer et revenir à la séance" } else { "Enregistrer l'exercice" }, description = - "Ajouter ce profil au catalogue local.", + if (editedExercise == null) { + "Ajouter ce profil au catalogue local." + } else { + "Conserver l'identité et mettre à jour le catalogue." + }, accent = colors.success, onClick = { - when ( - repository.createExercise( - NewExerciseProfile( - name = name, - recordingMode = - recordingMode, - trackingMode = - trackingMode, - dataFields = - fields, + val current = editedExercise + val result = + if (current == null) { + repository.createExercise( + NewExerciseProfile( + name = name, + recordingMode = + recordingMode, + trackingMode = + trackingMode, + dataFields = + fields, + ), ) - ) - ) { + } else { + repository.editExercise( + ExerciseEditInput( + exerciseId = current.exerciseId, + name = name, + recordingMode = recordingMode, + trackingMode = trackingMode, + dataFields = fields, + ), + ) + } + when (result) { is CreateExerciseResult.Created -> { message = null onSaved() @@ -279,10 +350,50 @@ fun ExerciseScreen( message = "Profil ou nom invalide." } + + is EditExerciseResult.Saved -> { + message = null + editedExercise = null + onSaved() + } + + EditExerciseResult.Conflict -> { + message = "Un autre exercice porte déjà ce nom." + } + + EditExerciseResult.InvalidNameOrProfile -> { + message = "Nom ou profil invalide." + } + + EditExerciseResult.IncompatibleProfileChange -> { + message = + "Le profil ne peut pas changer après utilisation." + } + + EditExerciseResult.DatabaseError -> { + message = "Enregistrement en base impossible." + } } }, ) + if (editedExercise != null) { + TrainlogAction( + label = "Annuler", + description = "Revenir au catalogue sans modification.", + accent = colors.muted, + onClick = { + editedExercise = null + name = "" + recordingMode = RecordingMode.SETS + trackingMode = TrackingMode.REPS + speed = false + distance = false + message = null + }, + ) + } + if (message != null) { TrainlogInfo( text = @@ -292,6 +403,22 @@ fun ExerciseScreen( } } + TrainlogFrame(title = "EXERCICES EXISTANTS", active = false) { + val exercises = repository.listExercises() + if (exercises.isEmpty()) { + TrainlogInfo("Aucun exercice enregistré.") + } else { + exercises.forEach { exercise -> + TrainlogAction( + label = "Modifier · ${exercise.name}", + description = "Modifier le nom ou le profil si disponible.", + accent = colors.accent, + onClick = { startEditing(exercise) }, + ) + } + } + } + TrainlogFrame( title = "CONTRAT", active = false, @@ -355,6 +482,7 @@ private fun TrainlogChoiceGroup( private fun TrainlogChoice( label: String, selected: Boolean, + enabled: Boolean = true, onClick: () -> Unit, ) { val colors = @@ -372,7 +500,10 @@ private fun TrainlogChoice( colors.surface } ) - .clickable(onClick = onClick) + .clickable( + enabled = enabled, + onClick = onClick, + ) .padding( horizontal = 10.dp, vertical = 9.dp, @@ -390,6 +521,8 @@ private fun TrainlogChoice( color = if (selected) { colors.warning + } else if (!enabled) { + colors.muted } else { colors.text }, diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/HomeScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/HomeScreen.kt index 50a56a2..48931f1 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/HomeScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/HomeScreen.kt @@ -1,18 +1,102 @@ package com.labfytools.trainlog.ui import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.labfytools.trainlog.model.ActiveSessionDraft +import com.labfytools.trainlog.model.SessionType +import com.labfytools.trainlog.ui.theme.LocalTrainlogColors @Composable fun HomeScreen( + activeDraft: ActiveSessionDraft?, + draftError: String?, onSession: () -> Unit, + onDiscardDraft: () -> Unit, onExercise: () -> Unit, onBody: () -> Unit, onHistory: () -> Unit, onSync: () -> Unit, ) { + val colors = LocalTrainlogColors.current + var confirmingDiscard by + remember(activeDraft != null) { + mutableStateOf(false) + } + TrainlogScreen( subtitle = "A C C U E I L" ) { + if (activeDraft != null) { + TrainlogFrame( + title = "SÉANCE EN COURS" + ) { + TrainlogAction( + label = + "Reprendre la séance en cours", + description = + (if ( + activeDraft.sessionType == + SessionType.MAX_TEST + ) { + "Test max" + } else { + "Entraînement" + }) + + " · ${activeDraft.exercises.size} exercice(s)", + accent = colors.success, + onClick = onSession, + ) + + TrainlogAction( + label = + "Supprimer la séance en cours", + description = + "Supprimer le brouillon, sans modifier l'historique.", + accent = colors.error, + onClick = { + confirmingDiscard = true + }, + ) + + if (confirmingDiscard) { + TrainlogAction( + label = + "Confirmer la suppression", + description = + "Abandonner définitivement cette séance en cours.", + accent = colors.error, + onClick = { + confirmingDiscard = false + onDiscardDraft() + }, + ) + TrainlogAction( + label = "Annuler", + description = + "Conserver la séance en cours.", + accent = colors.muted, + onClick = { + confirmingDiscard = false + }, + ) + } + } + } + + if (draftError != null) { + TrainlogFrame( + title = "BROUILLON" + ) { + TrainlogInfo( + text = draftError, + color = colors.error, + ) + } + } + TrainlogFrame( title = "ENREGISTREMENT" ) { @@ -20,7 +104,11 @@ fun HomeScreen( label = "Enregistrer une séance", description = - "Saisir un entraînement et ses exercices.", + if (activeDraft == null) { + "Saisir un entraînement et ses exercices." + } else { + "Ouvrir la séance en cours sans l'écraser." + }, onClick = onSession, ) diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt index fb8eea6..f686644 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt @@ -15,19 +15,21 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.BasicText 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.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import com.labfytools.trainlog.data.SaveSessionResult +import com.labfytools.trainlog.data.ActiveDraftLoadResult +import com.labfytools.trainlog.data.ActiveDraftMutationResult +import com.labfytools.trainlog.data.FinalizeActiveDraftResult import com.labfytools.trainlog.data.TrainlogRepository +import com.labfytools.trainlog.model.ActiveSessionDraft import com.labfytools.trainlog.model.ExerciseDataFields import com.labfytools.trainlog.model.ExerciseProfile import com.labfytools.trainlog.model.RecordingMode -import com.labfytools.trainlog.model.SessionDraft +import com.labfytools.trainlog.model.SessionDraftForm import com.labfytools.trainlog.model.SessionExerciseDraft import com.labfytools.trainlog.model.SessionSetDraft import com.labfytools.trainlog.model.SessionType @@ -53,41 +55,64 @@ fun SessionScreen( repository.listExercises() } - var selectedExercise by - remember( - catalogRevision - ) { - mutableStateOf< - ExerciseProfile? - >(null) + val initialLoad = + remember(catalogRevision) { + repository.loadActiveSessionDraft() } - var draftExercises by - remember { + var activeDraft by + remember(catalogRevision) { mutableStateOf( - emptyList< - SessionExerciseDraft - >() + (initialLoad as? + ActiveDraftLoadResult.Loaded) + ?.draft ) } - var sessionType by - remember { - mutableStateOf( - SessionType.TRAINING - ) - } - - var sessionRevision by - remember { - mutableIntStateOf(0) - } - var message by - remember { + remember(catalogRevision) { mutableStateOf< String? - >(null) + >( + when (initialLoad) { + is ActiveDraftLoadResult.Error -> + initialLoad.message + + ActiveDraftLoadResult.None -> + "Aucune séance en cours." + + is ActiveDraftLoadResult.Loaded -> + initialLoad.warning + } + ) + } + + var confirmingDiscard by + remember { + mutableStateOf(false) + } + + val persistDraft: + (ActiveSessionDraft, String?) -> Unit = + { updated, successMessage -> + when ( + val result = + repository + .saveActiveSessionDraft( + updated + ) + ) { + ActiveDraftMutationResult.Saved -> { + activeDraft = updated + message = successMessage + } + + is ActiveDraftMutationResult.Error -> { + message = + "Brouillon non sauvegardé : " + + result.message + } + } } TrainlogScreen( @@ -96,18 +121,52 @@ fun SessionScreen( TrainlogAction( label = "< Retour", description = - "Revenir à l'accueil.", + "Revenir à l'accueil sans supprimer la séance en cours.", + /* CONTRACT: ordinary navigation never owns draft deletion. */ onClick = onBack, accent = colors.muted, ) + if (activeDraft == null) { + TrainlogFrame( + title = "ERREUR" + ) { + TrainlogInfo( + text = message.orEmpty(), + color = colors.error, + ) + } + return@TrainlogScreen + } + + val currentDraft = activeDraft!! + + val lastWriteFailed = + message?.startsWith( + "Brouillon non sauvegardé" + ) == true + TrainlogInfo( + text = + if (lastWriteFailed) { + "Dernière modification non sauvegardée." + } else { + "Séance sauvegardée localement." + }, + color = + if (lastWriteFailed) { + colors.error + } else { + colors.muted + }, + ) + TrainlogFrame( title = "TYPE DE SEANCE" ) { TrainlogAction( label = if ( - sessionType == + currentDraft.sessionType == SessionType.TRAINING ) { "[✓] Entraînement" @@ -118,7 +177,7 @@ fun SessionScreen( "Séance normale de travail.", accent = if ( - sessionType == + currentDraft.sessionType == SessionType.TRAINING ) { colors.success @@ -126,15 +185,20 @@ fun SessionScreen( colors.muted }, onClick = { - sessionType = - SessionType.TRAINING + persistDraft( + currentDraft.copy( + sessionType = + SessionType.TRAINING + ), + null, + ) }, ) TrainlogAction( label = if ( - sessionType == + currentDraft.sessionType == SessionType.MAX_TEST ) { "[✓] Test max" @@ -145,7 +209,7 @@ fun SessionScreen( "Séance explicitement dédiée à une mesure de max.", accent = if ( - sessionType == + currentDraft.sessionType == SessionType.MAX_TEST ) { colors.warning @@ -153,8 +217,13 @@ fun SessionScreen( colors.muted }, onClick = { - sessionType = - SessionType.MAX_TEST + persistDraft( + currentDraft.copy( + sessionType = + SessionType.MAX_TEST + ), + null, + ) }, ) } @@ -166,7 +235,7 @@ fun SessionScreen( text = "Type : " + if ( - sessionType == + currentDraft.sessionType == SessionType.MAX_TEST ) { "TEST MAX" @@ -175,7 +244,7 @@ fun SessionScreen( }, color = if ( - sessionType == + currentDraft.sessionType == SessionType.MAX_TEST ) { colors.warning @@ -185,13 +254,13 @@ fun SessionScreen( ) if ( - draftExercises.isEmpty() + currentDraft.exercises.isEmpty() ) { TrainlogInfo( "Aucun exercice ajouté." ) } else { - draftExercises + currentDraft.exercises .forEachIndexed { index, draft -> @@ -214,19 +283,18 @@ fun SessionScreen( accent = colors.error, onClick = { - draftExercises = - draftExercises - .filterIndexed { - itemIndex, - _ -> - itemIndex != - index - } - - sessionRevision += 1 - - message = - "Exercice retiré de la séance." + persistDraft( + currentDraft.copy( + exercises = + currentDraft.exercises + .filterIndexed { + itemIndex, + _ -> + itemIndex != index + } + ), + "Exercice retiré de la séance.", + ) }, ) } @@ -249,7 +317,7 @@ fun SessionScreen( exercise -> val alreadyAdded = - draftExercises.any { + currentDraft.exercises.any { it.exercise.exerciseId == exercise.exerciseId } @@ -258,7 +326,8 @@ fun SessionScreen( exercise = exercise, selected = - selectedExercise + currentDraft.form + .selectedExercise ?.exerciseId == exercise.exerciseId, disabled = @@ -267,10 +336,16 @@ fun SessionScreen( if ( !alreadyAdded ) { - selectedExercise = - exercise - - message = null + persistDraft( + currentDraft.copy( + form = + currentDraft.form.copy( + selectedExercise = + exercise + ) + ), + null, + ) } }, ) @@ -278,32 +353,47 @@ fun SessionScreen( } } - if ( - selectedExercise != null - ) { + val editingExercise = + currentDraft.form.selectedExercise + + if (editingExercise != null) { SessionExerciseForm( key = - selectedExercise!! - .exerciseId, + editingExercise.exerciseId, exercise = - selectedExercise!!, + editingExercise, + initialForm = + currentDraft.form, + onFormChanged = { + form -> + persistDraft( + currentDraft.copy( + form = form + ), + null, + ) + }, onCancel = { - selectedExercise = - null + persistDraft( + currentDraft.copy( + form = + SessionDraftForm() + ), + null, + ) }, onAdd = { draft -> - draftExercises = - draftExercises + - draft - - selectedExercise = - null - - sessionRevision += 1 - - message = - "Exercice ajouté à la séance." + persistDraft( + currentDraft.copy( + exercises = + currentDraft.exercises + + draft, + form = + SessionDraftForm(), + ), + "Exercice ajouté à la séance.", + ) }, ) } @@ -326,59 +416,87 @@ fun SessionScreen( TrainlogFrame( title = "ENREGISTREMENT", active = - draftExercises.isNotEmpty(), + currentDraft.exercises.isNotEmpty(), ) { TrainlogAction( label = "Enregistrer la séance", description = - "${draftExercises.size} exercice(s) dans la séance.", + "${currentDraft.exercises.size} exercice(s) dans la séance.", accent = colors.success, onClick = { when ( val result = - repository - .saveSession( - SessionDraft( - exercises = - draftExercises, - sessionType = - sessionType, - ) - ) + repository.finalizeActiveSessionDraft() ) { - is SaveSessionResult.Saved -> { - draftExercises = - emptyList() - - selectedExercise = - null - - sessionType = - SessionType.TRAINING - - sessionRevision += 1 - - message = - "Séance enregistrée." - + is FinalizeActiveDraftResult.Saved -> { onSessionSaved() + onBack() } - SaveSessionResult.Invalid -> { - message = - "Séance invalide." + is FinalizeActiveDraftResult.Invalid -> { + message = result.message } - SaveSessionResult.DatabaseError -> { + is FinalizeActiveDraftResult.DatabaseError -> { message = - "Erreur base locale." + "Échec de finalisation, brouillon conservé : " + + result.message } } }, ) + TrainlogAction( + label = "Supprimer la séance en cours", + description = + "Supprimer uniquement ce brouillon local.", + accent = colors.error, + onClick = { + confirmingDiscard = true + }, + ) + + if (confirmingDiscard) { + TrainlogInfo( + text = + "Cette suppression n'ajoutera rien à l'historique.", + color = colors.error, + ) + TrainlogAction( + label = "Confirmer la suppression", + description = + "Supprimer définitivement la séance en cours.", + accent = colors.error, + onClick = { + when ( + val result = + repository + .discardActiveSessionDraft() + ) { + ActiveDraftMutationResult.Saved -> { + confirmingDiscard = false + onBack() + } + + is ActiveDraftMutationResult.Error -> { + message = result.message + } + } + }, + ) + TrainlogAction( + label = "Annuler", + description = + "Conserver la séance en cours.", + accent = colors.muted, + onClick = { + confirmingDiscard = false + }, + ) + } + if ( message != null ) { @@ -387,8 +505,6 @@ fun SessionScreen( message.orEmpty(), color = if ( - message == - "Séance enregistrée." || message == "Exercice ajouté à la séance." || message == @@ -479,6 +595,8 @@ private fun CatalogChoice( private fun SessionExerciseForm( key: String, exercise: ExerciseProfile, + initialForm: SessionDraftForm, + onFormChanged: (SessionDraftForm) -> Unit, onCancel: () -> Unit, onAdd: (SessionExerciseDraft) -> @@ -489,27 +607,37 @@ private fun SessionExerciseForm( var setCountText by remember(key) { - mutableStateOf("3") + mutableStateOf( + initialForm.setCountText + ) } var repsText by remember(key) { - mutableStateOf("3x10") + mutableStateOf( + initialForm.repsText + ) } var durationText by remember(key) { - mutableStateOf("30") + mutableStateOf( + initialForm.durationText + ) } var speedText by remember(key) { - mutableStateOf("") + mutableStateOf( + initialForm.speedText + ) } var distanceText by remember(key) { - mutableStateOf("") + mutableStateOf( + initialForm.distanceText + ) } var error by @@ -547,6 +675,16 @@ private fun SessionExerciseForm( onValueChange = { repsText = it error = null + onFormChanged( + currentForm( + exercise, + setCountText, + it, + durationText, + speedText, + distanceText, + ) + ) }, ) @@ -565,6 +703,16 @@ private fun SessionExerciseForm( onValueChange = { setCountText = it error = null + onFormChanged( + currentForm( + exercise, + it, + repsText, + durationText, + speedText, + distanceText, + ) + ) }, ) @@ -576,6 +724,16 @@ private fun SessionExerciseForm( onValueChange = { durationText = it error = null + onFormChanged( + currentForm( + exercise, + setCountText, + repsText, + it, + speedText, + distanceText, + ) + ) }, ) } @@ -588,6 +746,16 @@ private fun SessionExerciseForm( onValueChange = { durationText = it error = null + onFormChanged( + currentForm( + exercise, + setCountText, + repsText, + it, + speedText, + distanceText, + ) + ) }, ) @@ -604,6 +772,16 @@ private fun SessionExerciseForm( onValueChange = { speedText = it error = null + onFormChanged( + currentForm( + exercise, + setCountText, + repsText, + durationText, + it, + distanceText, + ) + ) }, ) } @@ -621,6 +799,16 @@ private fun SessionExerciseForm( onValueChange = { distanceText = it error = null + onFormChanged( + currentForm( + exercise, + setCountText, + repsText, + durationText, + speedText, + it, + ) + ) }, ) } @@ -682,6 +870,23 @@ private fun SessionExerciseForm( } } +private fun currentForm( + exercise: ExerciseProfile, + setCountText: String, + repsText: String, + durationText: String, + speedText: String, + distanceText: String, +): SessionDraftForm = + SessionDraftForm( + selectedExercise = exercise, + setCountText = setCountText, + repsText = repsText, + durationText = durationText, + speedText = speedText, + distanceText = distanceText, + ) + @Composable private fun SessionNumberField( label: String, diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt index 60b6b60..76c0988 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt @@ -10,6 +10,8 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import com.labfytools.trainlog.data.ActiveDraftLoadResult +import com.labfytools.trainlog.data.ActiveDraftMutationResult import com.labfytools.trainlog.data.TrainlogRepository import com.labfytools.trainlog.data.SyncExporter import com.labfytools.trainlog.data.CatalogInboxResult @@ -52,6 +54,16 @@ fun TrainlogApp( mutableIntStateOf(0) } + var draftRevision by + remember { + mutableIntStateOf(0) + } + + var draftMessage by + remember { + mutableStateOf(null) + } + var selectedSessionId by remember { mutableStateOf( @@ -99,11 +111,74 @@ fun TrainlogApp( } when (screen) { - TrainlogScreenId.HOME -> + TrainlogScreenId.HOME -> { + val draftLoad = + remember( + draftRevision, + catalogRevision, + ) { + repository.loadActiveSessionDraft() + } + HomeScreen( + activeDraft = + (draftLoad as? + ActiveDraftLoadResult.Loaded) + ?.draft, + draftError = + draftMessage + ?: (draftLoad as? + ActiveDraftLoadResult.Error) + ?.message + ?: (draftLoad as? + ActiveDraftLoadResult.Loaded) + ?.warning, onSession = { - screen = - TrainlogScreenId.SESSION + when (draftLoad) { + is ActiveDraftLoadResult.Loaded -> { + draftMessage = null + screen = TrainlogScreenId.SESSION + } + + ActiveDraftLoadResult.None -> { + when ( + val result = + repository + .startActiveSessionDraft() + ) { + ActiveDraftMutationResult.Saved -> { + draftMessage = null + draftRevision += 1 + screen = + TrainlogScreenId.SESSION + } + + is ActiveDraftMutationResult.Error -> { + draftMessage = result.message + } + } + } + + is ActiveDraftLoadResult.Error -> { + draftMessage = draftLoad.message + } + } + }, + onDiscardDraft = { + when ( + val result = + repository + .discardActiveSessionDraft() + ) { + ActiveDraftMutationResult.Saved -> { + draftMessage = null + draftRevision += 1 + } + + is ActiveDraftMutationResult.Error -> { + draftMessage = result.message + } + } }, onExercise = { exerciseReturnTarget = @@ -125,6 +200,7 @@ fun TrainlogApp( TrainlogScreenId.SYNC }, ) + } TrainlogScreenId.SESSION -> SessionScreen( @@ -132,6 +208,9 @@ fun TrainlogApp( catalogRevision = catalogRevision, onBack = { + /* WHY: Back changes routing only; the repository remains + * the canonical owner of the in-progress workout. */ + draftRevision += 1 screen = TrainlogScreenId.HOME }, @@ -144,6 +223,7 @@ fun TrainlogApp( }, onSessionSaved = { exporter.exportMobileBundle() + draftRevision += 1 }, ) diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogComponents.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogComponents.kt index cc0782a..f6101bc 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogComponents.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogComponents.kt @@ -3,7 +3,6 @@ package com.labfytools.trainlog.ui import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.IntrinsicSize @@ -27,7 +26,6 @@ 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.focus.onFocusChanged import androidx.compose.ui.graphics.Color @@ -38,15 +36,6 @@ import androidx.compose.ui.unit.sp import com.labfytools.trainlog.ui.theme.LocalTrainlogColors import com.labfytools.trainlog.ui.theme.TrainlogTypography -private val FullAsciiBanner = - """ -TTTTT RRRR AAA IIIII N N L OOO GGG - T R R A A I NN N L O O G - T RRRR AAAAA I N N N L O O G GG - T R R A A I N NN L O O G G - T R R A A IIIII N N LLLLL OOO GGG -""".trimIndent() - @Composable fun TrainlogScreen( subtitle: String, @@ -87,53 +76,33 @@ private fun TrainlogBanner( val colors = LocalTrainlogColors.current - BoxWithConstraints( + Column( modifier = Modifier .fillMaxWidth() .padding(bottom = 18.dp) ) { - val wide = - maxWidth >= 560.dp + /* WHY: TUI and Android share this compact plaque rather than separate + * brand treatments. The terminal box becomes flat spacing on touch. */ + BasicText( + text = "◆ TRAINLOG ◆", + style = + TrainlogTypography.banner.copy( + color = colors.accent, + fontWeight = FontWeight.Bold, + fontSize = 21.sp, + ), + ) - Column( - modifier = - Modifier.fillMaxWidth(), - horizontalAlignment = - Alignment.Start, - ) { - BasicText( - text = - if (wide) { - FullAsciiBanner - } else { - "T R A I N L O G" - }, - style = - TrainlogTypography.banner.copy( - color = colors.accent, - fontWeight = FontWeight.Bold, - fontSize = - if (wide) { - 14.sp - } else { - 21.sp - }, - ), - ) - - BasicText( - text = subtitle, - modifier = - Modifier.padding(top = 5.dp), - style = - TrainlogTypography.small.copy( - color = colors.muted, - fontWeight = - FontWeight.Bold, - ), - ) - } + BasicText( + text = subtitle, + modifier = Modifier.padding(top = 5.dp), + style = + TrainlogTypography.small.copy( + color = colors.muted, + fontWeight = FontWeight.Bold, + ), + ) } } diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt new file mode 100644 index 0000000..462f2f4 --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt @@ -0,0 +1,553 @@ +package com.labfytools.trainlog.data + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import androidx.test.core.app.ApplicationProvider +import com.labfytools.trainlog.model.ActiveSessionDraft +import com.labfytools.trainlog.model.ExerciseDataFields +import com.labfytools.trainlog.model.ExerciseEditInput +import com.labfytools.trainlog.model.ExerciseProfile +import com.labfytools.trainlog.model.NewExerciseProfile +import com.labfytools.trainlog.model.RecordingMode +import com.labfytools.trainlog.model.SessionDraft +import com.labfytools.trainlog.model.SessionDraftForm +import com.labfytools.trainlog.model.SessionExerciseDraft +import com.labfytools.trainlog.model.SessionSetDraft +import com.labfytools.trainlog.model.SessionType +import com.labfytools.trainlog.model.TrackingMode +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class TrainlogRepositoryDraftTest { + private lateinit var context: Context + private lateinit var databaseName: String + private var repository: TrainlogRepository? = null + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + databaseName = "draft-test-${UUID.randomUUID()}.db" + } + + @After + fun tearDown() { + repository?.close() + context.deleteDatabase(databaseName) + } + + @Test + fun durableDraftRestoresEveryExerciseShapeAndRawForm() { + val first = openRepository() + val reps = createExercise(first, "Tractions", RecordingMode.SETS, TrackingMode.REPS) + val duration = createExercise(first, "Gainage", RecordingMode.SETS, TrackingMode.DURATION) + val continuous = createExercise( + first, + "Course", + RecordingMode.CONTINUOUS, + TrackingMode.DURATION, + ExerciseDataFields.SPEED_KMH or ExerciseDataFields.DISTANCE_KM, + ) + val expected = ActiveSessionDraft( + exercises = listOf( + SessionExerciseDraft( + exercise = reps, + sets = listOf(4, 5, 6, 7).map { SessionSetDraft(reps = it) }, + ), + SessionExerciseDraft( + exercise = duration, + sets = listOf(20, 35, 50).map { SessionSetDraft(durationSeconds = it) }, + ), + SessionExerciseDraft( + exercise = continuous, + continuousDurationSeconds = 1_800, + speedKmh = 8.5, + distanceKm = 4.25, + ), + ), + sessionType = SessionType.MAX_TEST, + form = SessionDraftForm( + selectedExercise = reps, + setCountText = "4", + repsText = "4,5,6,", + durationText = "31", + speedText = "8,", + distanceText = "4.", + ), + ) + + assertEquals(ActiveDraftMutationResult.Saved, first.saveActiveSessionDraft(expected)) + first.close() + repository = null + + val restored = loadDraft(openRepository()) + assertEquals(SessionType.MAX_TEST, restored.sessionType) + assertEquals(expected.exercises, restored.exercises) + assertEquals(expected.form, restored.form) + assertTrue(restored.updatedAt.isNotBlank()) + } + + @Test + fun removingExerciseAndDiscardingDraftDoNotDeleteCatalog() { + val repo = openRepository() + val kept = createExercise(repo, "Vélo", RecordingMode.CONTINUOUS, TrackingMode.DURATION) + val removed = createExercise(repo, "Pompes", RecordingMode.SETS, TrackingMode.REPS) + val initial = ActiveSessionDraft( + exercises = listOf( + SessionExerciseDraft( + exercise = removed, + sets = listOf(SessionSetDraft(reps = 12)), + ), + SessionExerciseDraft( + exercise = kept, + continuousDurationSeconds = 900, + ), + ), + ) + assertEquals(ActiveDraftMutationResult.Saved, repo.saveActiveSessionDraft(initial)) + assertEquals( + ActiveDraftMutationResult.Saved, + repo.saveActiveSessionDraft(initial.copy(exercises = initial.exercises.drop(1))), + ) + + repo.close() + repository = null + val fresh = openRepository() + val restored = loadDraft(fresh) + assertEquals(listOf(kept.exerciseId), restored.exercises.map { it.exercise.exerciseId }) + assertTrue(fresh.listExercises().any { it.exerciseId == removed.exerciseId }) + assertEquals(ActiveDraftMutationResult.Saved, fresh.discardActiveSessionDraft()) + assertEquals(ActiveDraftLoadResult.None, fresh.loadActiveSessionDraft()) + assertEquals(2, fresh.listExercises().size) + assertTrue(fresh.listSessions().isEmpty()) + } + + @Test + fun finalizeIsAtomicAndDraftNeverExportsBeforeCompletion() { + val repo = openRepository() + val exercise = createExercise(repo, "Squat", RecordingMode.SETS, TrackingMode.REPS) + val draft = ActiveSessionDraft( + exercises = listOf( + SessionExerciseDraft( + exercise = exercise, + sets = listOf(8, 7, 6).map { SessionSetDraft(reps = it) }, + ) + ) + ) + assertEquals(ActiveDraftMutationResult.Saved, repo.saveActiveSessionDraft(draft)) + assertEquals(0, JSONObject(repo.buildMobileExportJson()).getJSONArray("sessions").length()) + val result = repo.finalizeActiveSessionDraft() + assertTrue(result is FinalizeActiveDraftResult.Saved) + assertEquals(ActiveDraftLoadResult.None, repo.loadActiveSessionDraft()) + assertEquals(1, repo.listSessions().size) + val exported = JSONObject(repo.buildMobileExportJson()).getJSONArray("sessions") + assertEquals(1, exported.length()) + assertEquals(3, exported.getJSONObject(0).getJSONArray("exercises").getJSONObject(0).getJSONArray("sets").length()) + assertTrue( + repo.finalizeActiveSessionDraft() is + FinalizeActiveDraftResult.Invalid + ) + assertEquals(1, repo.listSessions().size) + } + + @Test + fun finalizationFailureRollsBackCompletedRowsAndKeepsDraft() { + val repo = openRepository() + val exercise = createExercise(repo, "Row", RecordingMode.SETS, TrackingMode.REPS) + assertEquals( + ActiveDraftMutationResult.Saved, + repo.saveActiveSessionDraft( + ActiveSessionDraft( + exercises = listOf( + SessionExerciseDraft( + exercise = exercise, + sets = listOf(SessionSetDraft(reps = 10)), + ) + ) + ) + ), + ) + SQLiteDatabase.openDatabase( + context.getDatabasePath(databaseName).path, + null, + SQLiteDatabase.OPEN_READWRITE, + ).use { + it.execSQL( + "CREATE TRIGGER reject_completed_draft BEFORE INSERT ON session_exercises " + + "BEGIN SELECT RAISE(ABORT, 'forced finalization failure'); END;" + ) + } + + val result = repo.finalizeActiveSessionDraft() + assertTrue(result is FinalizeActiveDraftResult.DatabaseError) + assertTrue(repo.listSessions().isEmpty()) + assertEquals(1, loadDraft(repo).exercises.size) + } + + @Test + fun catalogIdentityReconciliationKeepsDraftAndRawForm() { + val repo = openRepository() + val local = createExercise(repo, "Marche", RecordingMode.CONTINUOUS, TrackingMode.DURATION) + val raw = SessionDraftForm( + selectedExercise = local, + durationText = "12,", + speedText = "5,", + ) + assertEquals( + ActiveDraftMutationResult.Saved, + repo.saveActiveSessionDraft( + ActiveSessionDraft( + exercises = listOf( + SessionExerciseDraft( + exercise = local, + continuousDurationSeconds = 600, + ) + ), + form = raw, + ) + ), + ) + val canonicalId = "ex_${UUID.randomUUID()}" + val catalog = JSONObject() + .put("format", "trainlog-pc-catalog") + .put("version", 1) + .put( + "exercises", + org.json.JSONArray().put( + JSONObject() + .put("exercise_id", canonicalId) + .put("name", "Marche") + .put("recording_mode", "continuous") + .put("tracking_mode", "duration") + .put("data_fields", 0) + ) + ) + assertTrue(repo.applyPcCatalogJson(catalog.toString()) is PcCatalogImportResult.Applied) + val restored = loadDraft(repo) + assertEquals(canonicalId, restored.exercises.single().exercise.exerciseId) + assertEquals(canonicalId, restored.form.selectedExercise?.exerciseId) + assertEquals("12,", restored.form.durationText) + assertEquals("5,", restored.form.speedText) + } + + @Test + fun renameKeepsStableIdHistoryAndActiveDraftReferences() { + val repo = openRepository() + val original = createExercise(repo, "un marche", RecordingMode.CONTINUOUS, TrackingMode.DURATION) + val completed = + repo.saveSession( + SessionDraft( + exercises = listOf( + SessionExerciseDraft( + exercise = original, + continuousDurationSeconds = 300, + ), + ), + ), + ) + assertTrue(completed is SaveSessionResult.Saved) + assertEquals( + ActiveDraftMutationResult.Saved, + repo.saveActiveSessionDraft( + ActiveSessionDraft( + exercises = listOf( + SessionExerciseDraft( + exercise = original, + continuousDurationSeconds = 600, + ), + ), + form = SessionDraftForm(selectedExercise = original), + ), + ), + ) + + val result = repo.editExercise( + ExerciseEditInput( + original.exerciseId, + " Marche ", + original.recordingMode, + original.trackingMode, + original.dataFields, + ), + ) + assertTrue(result is EditExerciseResult.Saved) + result as EditExerciseResult.Saved + assertEquals(original.exerciseId, result.exercise.exerciseId) + assertEquals("Marche", result.exercise.name) + assertEquals("marche", result.exercise.normalizedName) + val restored = loadDraft(repo) + assertEquals(original.exerciseId, restored.exercises.single().exercise.exerciseId) + assertEquals("Marche", restored.exercises.single().exercise.name) + assertEquals(original.exerciseId, restored.form.selectedExercise?.exerciseId) + val detail = repo.getSessionDetail((completed as SaveSessionResult.Saved).sessionId) + assertNotNull(detail) + assertEquals("Marche", detail?.exercises?.single()?.exerciseName) + SQLiteDatabase.openDatabase( + context.getDatabasePath(databaseName).path, + null, + SQLiteDatabase.OPEN_READONLY, + ).use { db -> + db.rawQuery( + """ + SELECT e.exercise_id + FROM session_exercises AS se + JOIN exercises AS e ON e.id = se.exercise_row_id + LIMIT 1; + """.trimIndent(), + null, + ).use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals(original.exerciseId, cursor.getString(0)) + } + } + } + + @Test + fun renameRejectsDuplicateAndInvalidNamesAndLocksReferencedProfile() { + val repo = openRepository() + val referenced = createExercise(repo, "Marche", RecordingMode.CONTINUOUS, TrackingMode.DURATION) + createExercise(repo, "Course", RecordingMode.CONTINUOUS, TrackingMode.DURATION) + assertEquals( + ActiveDraftMutationResult.Saved, + repo.saveActiveSessionDraft( + ActiveSessionDraft( + exercises = listOf( + SessionExerciseDraft( + exercise = referenced, + continuousDurationSeconds = 30, + ), + ), + ), + ), + ) + assertFalse(repo.canEditExerciseProfile(referenced.exerciseId)) + assertEquals( + EditExerciseResult.Conflict, + repo.editExercise( + ExerciseEditInput( + referenced.exerciseId, + " course ", + referenced.recordingMode, + referenced.trackingMode, + referenced.dataFields, + ), + ), + ) + assertEquals( + EditExerciseResult.InvalidNameOrProfile, + repo.editExercise( + ExerciseEditInput( + referenced.exerciseId, + " ", + referenced.recordingMode, + referenced.trackingMode, + referenced.dataFields, + ), + ), + ) + assertEquals( + EditExerciseResult.IncompatibleProfileChange, + repo.editExercise( + ExerciseEditInput( + referenced.exerciseId, + referenced.name, + RecordingMode.SETS, + TrackingMode.REPS, + ExerciseDataFields.NONE, + ), + ), + ) + } + + @Test + fun pcCatalogRenameUpdatesSameRowWithoutDuplicate() { + val repo = openRepository() + val local = createExercise(repo, "un marche", RecordingMode.CONTINUOUS, TrackingMode.DURATION) + val catalog = JSONObject() + .put("format", "trainlog-pc-catalog") + .put("version", 1) + .put( + "exercises", + org.json.JSONArray().put( + JSONObject() + .put("exercise_id", local.exerciseId) + .put("name", "Marche") + .put("recording_mode", "continuous") + .put("tracking_mode", "duration") + .put("data_fields", 0), + ), + ) + assertTrue(repo.applyPcCatalogJson(catalog.toString()) is PcCatalogImportResult.Applied) + assertEquals(1, repo.listExercises().size) + assertEquals(local.exerciseId, repo.listExercises().single().exerciseId) + assertEquals("Marche", repo.listExercises().single().name) + } + + @Test + fun missingSelectedExerciseClearsOnlyFormAndReturnsDiagnostic() { + val repo = openRepository() + val added = createExercise(repo, "Conservé", RecordingMode.SETS, TrackingMode.REPS) + val selected = createExercise(repo, "Supprimé", RecordingMode.SETS, TrackingMode.REPS) + assertEquals( + ActiveDraftMutationResult.Saved, + repo.saveActiveSessionDraft( + ActiveSessionDraft( + exercises = listOf( + SessionExerciseDraft( + exercise = added, + sets = listOf(SessionSetDraft(reps = 8)), + ) + ), + form = SessionDraftForm( + selectedExercise = selected, + repsText = "4,5,6,", + ), + ) + ), + ) + SQLiteDatabase.openDatabase( + context.getDatabasePath(databaseName).path, + null, + SQLiteDatabase.OPEN_READWRITE, + ).use { db -> + db.execSQL("PRAGMA foreign_keys = ON;") + db.delete( + "exercises", + "exercise_id = ?", + arrayOf(selected.exerciseId), + ) + } + + val result = repo.loadActiveSessionDraft() + assertTrue(result is ActiveDraftLoadResult.Loaded) + result as ActiveDraftLoadResult.Loaded + assertNotNull(result.warning) + assertEquals(listOf(added.exerciseId), result.draft.exercises.map { it.exercise.exerciseId }) + assertEquals(null, result.draft.form.selectedExercise) + assertEquals("4,5,6,", result.draft.form.repsText) + } + + @Test + fun databaseOpenFailureIsReturnedInsteadOfEscapingMutationApis() { + val blocked = TrainlogRepository( + context, + "/proc/trainlog-draft-${UUID.randomUUID()}.db", + ) + val save = blocked.saveActiveSessionDraft(ActiveSessionDraft()) + val finalize = blocked.finalizeActiveSessionDraft() + assertTrue(save is ActiveDraftMutationResult.Error) + assertTrue(finalize is FinalizeActiveDraftResult.DatabaseError) + blocked.close() + } + + @Test + fun versionThreeMigrationPreservesCompletedAndBodyData() { + createVersionThreeFixture(context.getDatabasePath(databaseName).path) + val repo = openRepository() + + assertEquals(1, repo.listExercises().size) + assertEquals(1, repo.listSessions().size) + assertEquals(1, repo.listBodyObservations().size) + assertEquals(ActiveDraftLoadResult.None, repo.loadActiveSessionDraft()) + SQLiteDatabase.openDatabase( + context.getDatabasePath(databaseName).path, + null, + SQLiteDatabase.OPEN_READONLY, + ).use { db -> + db.rawQuery("PRAGMA user_version;", null).use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals(4, cursor.getInt(0)) + } + db.rawQuery("PRAGMA foreign_key_check;", null).use { cursor -> + assertFalse(cursor.moveToFirst()) + } + } + } + + private fun openRepository(): TrainlogRepository { + return TrainlogRepository(context, databaseName).also { repository = it } + } + + private fun loadDraft(repo: TrainlogRepository): ActiveSessionDraft { + val result = repo.loadActiveSessionDraft() + assertTrue(result is ActiveDraftLoadResult.Loaded) + return (result as ActiveDraftLoadResult.Loaded).draft + } + + private fun createExercise( + repo: TrainlogRepository, + name: String, + recordingMode: RecordingMode, + trackingMode: TrackingMode, + dataFields: Int = ExerciseDataFields.NONE, + ): ExerciseProfile { + val result = repo.createExercise( + NewExerciseProfile(name, recordingMode, trackingMode, dataFields) + ) + assertTrue(result is CreateExerciseResult.Created) + return (result as CreateExerciseResult.Created).exercise + } + + private fun createVersionThreeFixture(path: String) { + SQLiteDatabase.openOrCreateDatabase(path, null).use { db -> + db.execSQL( + "CREATE TABLE exercises(id INTEGER PRIMARY KEY, exercise_id TEXT NOT NULL UNIQUE, " + + "name TEXT NOT NULL, normalized_name TEXT NOT NULL UNIQUE, recording_mode TEXT NOT NULL, " + + "tracking_mode TEXT NOT NULL, data_fields INTEGER NOT NULL DEFAULT 0);" + ) + db.execSQL( + "CREATE TABLE sessions(id INTEGER PRIMARY KEY, session_id TEXT NOT NULL UNIQUE, " + + "started_at TEXT NOT NULL, session_type TEXT NOT NULL);" + ) + db.execSQL( + "CREATE TABLE session_exercises(id INTEGER PRIMARY KEY, session_row_id INTEGER NOT NULL " + + "REFERENCES sessions(id) ON DELETE CASCADE, exercise_row_id INTEGER NOT NULL " + + "REFERENCES exercises(id) ON DELETE RESTRICT, position INTEGER NOT NULL, " + + "recording_mode TEXT NOT NULL, tracking_mode TEXT NOT NULL, data_fields INTEGER NOT NULL, " + + "UNIQUE(session_row_id, position));" + ) + db.execSQL( + "CREATE TABLE performed_sets(id INTEGER PRIMARY KEY, session_exercise_row_id INTEGER NOT NULL " + + "REFERENCES session_exercises(id) ON DELETE CASCADE, position INTEGER NOT NULL, " + + "reps INTEGER, duration_seconds INTEGER, UNIQUE(session_exercise_row_id, position));" + ) + db.execSQL( + "CREATE TABLE continuous_activity(id INTEGER PRIMARY KEY, session_exercise_row_id INTEGER NOT NULL UNIQUE " + + "REFERENCES session_exercises(id) ON DELETE CASCADE, duration_seconds INTEGER NOT NULL, " + + "speed_kmh REAL, distance_km REAL);" + ) + db.execSQL( + "CREATE TABLE body_observations(id INTEGER PRIMARY KEY, observation_id TEXT NOT NULL UNIQUE, " + + "observed_at TEXT NOT NULL, body_weight_kg REAL, neck_cm REAL, shoulders_cm REAL, chest_cm REAL, " + + "waist_cm REAL, hips_cm REAL, left_arm_cm REAL, right_arm_cm REAL, left_forearm_cm REAL, " + + "right_forearm_cm REAL, left_thigh_cm REAL, right_thigh_cm REAL, left_calf_cm REAL, right_calf_cm REAL);" + ) + db.execSQL( + "INSERT INTO exercises VALUES(1, 'ex_fixture', 'Fixture', 'fixture', 'sets', 'reps', 0);" + ) + db.execSQL( + "INSERT INTO sessions VALUES(1, 'se_fixture', '2026-01-02T03:04:05+01:00', 'training');" + ) + db.execSQL( + "INSERT INTO session_exercises VALUES(1, 1, 1, 0, 'sets', 'reps', 0);" + ) + db.execSQL("INSERT INTO performed_sets VALUES(1, 1, 0, 9, NULL);") + db.execSQL( + "INSERT INTO body_observations(id, observation_id, observed_at, body_weight_kg) " + + "VALUES(1, 'bo_fixture', '2026-01-02T03:04:05+01:00', 70.5);" + ) + db.execSQL("PRAGMA user_version = 3;") + } + } +} diff --git a/docs/android.md b/docs/android.md index a146c87..52118e6 100644 --- a/docs/android.md +++ b/docs/android.md @@ -12,6 +12,7 @@ The desktop remains the canonical long-term history and analytics store. ```text Accueil +├── Reprendre la séance en cours (si un brouillon existe) ├── Enregistrer une séance ├── Enregistrer un exercice ├── Enregistrer des mensurations @@ -24,7 +25,7 @@ Accueil Android local database version: ```text -3 +4 ``` Domain tables cover: @@ -40,6 +41,11 @@ body_observations This database is Android-local. It is not copied to the PC. +Schema v4 adds `active_session_draft`, `draft_session_exercises`, +`draft_performed_sets` and `draft_continuous_activity`. The additive v3 -> v4 +migration preserves catalog, completed sessions/actuals and body observations. +Exactly one active draft is supported; it is separate from completed history. + ## 4. Exercise catalog Exercise creation records: @@ -62,6 +68,25 @@ collisions. An exercise may be created standalone or inline while building a session. +### Editing an exercise + +Every existing catalog item exposes **Modifier**. Editing a name trims its +input, recomputes `normalized_name`, and rejects a normalized-name collision. +The row retains its existing `exercise_id`; naming is presentation metadata, +not identity. Completed session rows and the active draft retain their catalog +row relationship and immediately resolve the renamed display text after reopen. + +Profile fields (`recording_mode`, `tracking_mode`, `data_fields`) are editable +only while an exercise has no completed-session or active-draft reference. Once +referenced, Android displays the lock and returns an explicit incompatible +profile result rather than silently reinterpreting work or creating another +exercise. Renaming remains available independently. + +The shared Compose `TrainlogScreen` header is used by Accueil, Séance, +Exercice, Mensurations, Historique, Détail séance and Sync. Its compact +`◆ TRAINLOG ◆` accent plaque and muted subtitle intentionally mirror the +Notcurses TUI identity in a flat mobile layout. + ## 5. Session recording Stable session identity: @@ -97,10 +122,34 @@ Continuous work does not create fake sets. ## 6. Session draft editing -Before a session is saved, an exercise already added to the draft can be -removed. +The repository durably saves every meaningful mutation, including session type, +exercise selection/addition/removal, actual values and raw form edits. Partial +text such as `4,5,6,` is retained without normalization. A failed write displays +a specific error and does not claim the latest change was saved. -Removing one exercise does not alter the exercise catalog entry itself. +Home shows **Reprendre la séance en cours** and an exercise-count/type summary. +The ordinary new-session action opens an existing draft without overwriting it. +Back returns Home and preserves the draft. Backgrounding, switching apps, +Activity/configuration recreation, background process death and force-stop with +relaunch preserve the draft; these paths were validated on the Samsung SM_G990B. + +**Retirer ** removes only that draft exercise and its actual values. +It does not change the catalog or completed history. Removal survives restart. +**Supprimer la séance en cours** requires deliberate confirmation; cancellation +preserves the draft. Confirmed deletion leaves no completed session or stale +resume action after relaunch. + +Final save validates the durable draft, inserts the completed session and actual +values, and removes the draft in one SQLite transaction. Failure rolls back and +retains the draft for retry; repeated completion does not create duplicates. +The existing completed-session save-time timestamp behavior is unchanged. + +PC catalog reconciliation preserves draft references through catalog row +ownership. If an editing selection no longer resolves, only the selection is +cleared; added exercises and raw text remain, with a specific diagnostic. +When a received or exported catalog entry has the same `exercise_id`, a changed +display name is reconciled in that same row. A different-ID normalized-name +collision is rejected, so a rename cannot become a duplicate exercise. ## 7. Session history @@ -155,6 +204,10 @@ session, body-observation, and PC-catalog updates. The user does not need a separate manual export step before synchronization. +An active draft is never included in completed history, session detail or this +snapshot. Synchronization continues to exchange completed data while the draft +stays local; no draft fields were added to the frozen mobile artifact. + ## 10. PC catalog access PC-created files are accessed through a persistent Storage Access Framework @@ -232,6 +285,14 @@ adb install -r app/build/outputs/apk/debug/app-debug.apk `local.properties` is local machine configuration and must not be committed. +Host regression suite: 8 tests. Device instrumentation: 5 tests (2 repository, +3 production-screen UI tests using an isolated database and no shared export). +The real device matrix additionally exercised production `MainActivity`, +including verified process exit with `am kill`, force-stop, configuration +relaunch, raw-form recovery, removal, discard and unchanged user data. Final-save +UI checks use isolated data so fictitious workouts do not enter user history. +See [tests](tests.md) for commands and the precise validation boundary. + ## 14. Non-goals Android is not intended to own: diff --git a/docs/architecture.md b/docs/architecture.md index 4bce0c0..29cf76a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -37,6 +37,7 @@ Android local SQLite is a capture store, not a synchronization format. Responsibilities: - exercise catalog entry; +- stable-ID exercise rename/editing; - workout-session recording; - performed set entry; - continuous-activity entry; @@ -64,7 +65,9 @@ The C17 core owns: ### TUI -The ncursesw layer owns interaction and rendering. +The Notcurses layer owns interaction and rendering. It is confined to the +desktop executable; persistence, synchronization, and core services have no +terminal-library dependency. It consumes core services for: @@ -132,13 +135,37 @@ body_observations ### Android -Android has an independent local SQLite schema. +Android has an independent local SQLite schema, currently v4. It mirrors domain concepts needed for capture, but its schema version is not coupled to the desktop schema. Synchronization exchanges domain artifacts rather than database files. +`TrainlogRepository` owns a singleton active-session draft, its ordered exercise +and actual-value children, and raw form text. Compose sends meaningful mutations +to that repository; lifecycle callbacks are not the sole persistence boundary. +Normal navigation never deletes the draft. Home restores the resume affordance +from SQLite after process recreation. + +Drafts use separate tables from completed sessions and are never export sources. +Finalization inserts the completed session and deletes the draft in one +transaction; failures retain the draft. Catalog row references preserve draft +identity through existing PC-catalog reconciliation. Missing editing-selection +recovery preserves the raw fields and added exercises with a specific warning. + +`TrainlogRepository.editExercise()` owns all Android exercise edits. It changes +the display name and normalized form in the existing catalog row identified by +`exercise_id`; foreign-key ownership consequently preserves completed history +and active drafts. A profile edit is admitted only before that row is referenced +by either completed or active-draft data. Android and desktop same-ID catalog +reconciliation apply name metadata in place and reject a collision with a +different stable ID. + +Compose presentation has one `TrainlogScreen` header component for every page. +It uses the TUI's compact accent `◆ TRAINLOG ◆` plaque and muted context line; +screen navigation and data ownership remain independent from the header. + ## 5. Compatibility boundaries ### Frozen Trainlog JSON v1 diff --git a/docs/current_state.md b/docs/current_state.md index f231327..efc42bc 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -1,6 +1,6 @@ # Current implementation state -Canonical snapshot: 2026-09-06. +Canonical snapshot: 2026-09-07. This document is the compact source of truth for the implemented Trainlog baseline. Detailed behavior belongs in the topic-specific documents. @@ -15,7 +15,23 @@ GATE_2_PERSISTENCE_AND_USABLE_TUI=PASS TRAINLOG_FORMAT_V1=FROZEN DESKTOP_SCHEMA_V5=PASS -ANDROID_LOCAL_DATABASE_V3=PASS +ANDROID_LOCAL_DATABASE_V4=PASS +ANDROID_SESSION_DRAFT_V1=PASS +ANDROID_DRAFT_DURABLE=PASS +ANDROID_DRAFT_BACKGROUND_SURVIVAL=PASS +ANDROID_DRAFT_PROCESS_DEATH_SURVIVAL=PASS +ANDROID_DRAFT_FORCE_STOP_SURVIVAL=PASS +ANDROID_SESSION_RESUME=PASS +ANDROID_DRAFT_FORM_RESTORE=PASS +ANDROID_DRAFT_EXERCISE_REMOVE=PASS +ANDROID_DRAFT_DISCARD=PASS +ANDROID_DRAFT_FINALIZE_ATOMIC=PASS +ANDROID_DRAFT_NOT_EXPORTED_AS_SESSION=PASS +EXERCISE_EDIT_V1=PASS +EXERCISE_RENAME_STABLE_ID=PASS +ANDROID_BANNER_PARITY_V1=PASS +ANDROID_INSTALL_ADB=PASS +ANDROID_USER_DATA_PRESERVED=PASS PROFILE_AWARE_EXERCISES=PASS CONTINUOUS_ACTIVITY=PASS @@ -31,7 +47,7 @@ ANDROID_SYNC_RECEIPT=PASS TUI_SYNC_LOG_SHOW=PASS BIDIRECTIONAL_SYNC_V1=PASS -DESKTOP_TESTS=21/21 PASS +DESKTOP_TESTS=22/22 PASS ANDROID_BUILD=PASS HARDWARE_SYNC_VALIDATION=PASS ``` @@ -40,7 +56,7 @@ HARDWARE_SYNC_VALIDATION=PASS Implemented: -- C17/ncursesw TUI; +- C17/Notcurses true-color TUI (72x20 minimum, UTF-8 prompts, resize fallback); - SQLite schema v5; - direct session entry; - persisted session detail and editing; @@ -71,8 +87,11 @@ Primary navigation: Implemented: - native Kotlin/Compose application; -- local SQLite database v3; +- local SQLite database v4, with non-destructive v3 -> v4 migration; +- one durable active-session draft, Home resume and raw-form restoration; +- explicit confirmed discard and atomic completed-save/draft-clear; - exercise creation; +- stable-ID exercise rename/editing with referenced-profile protection; - inline exercise creation during session entry; - profile-aware session recording; - heterogeneous repetition-set entry; @@ -85,6 +104,17 @@ Implemented: - Android-triggered synchronization request; - synchronization receipt handling. +The Android catalog exposes **Modifier** for every existing exercise. A rename +updates `name` and `normalized_name` in the original row, never creates an ID, +and remains valid for completed session and active-draft references. A profile +change is only accepted while the row has neither completed-session nor draft +references. Same-ID catalog reconciliation updates display metadata in place in +both Android and desktop import directions. + +All Android screens use the shared compact `◆ TRAINLOG ◆` header: the +Notcurses accent, muted context line, and flat touch layout reproduce the TUI +plaque without literal terminal box drawing. + ## Synchronization Canonical exchange directory: @@ -120,7 +150,7 @@ No mounted Android filesystem is required. Desktop: ```text -21/21 Meson tests PASS +22/22 Meson tests PASS frozen JSON validator PASS import-contract validator PASS git diff --check PASS @@ -130,13 +160,18 @@ Android: ```text assembleDebug PASS +host repository tests 8/8 PASS +device instrumentation 5/5 PASS +real Samsung background/process-death/force-stop/resume matrix PASS +real migration and original user-data preservation PASS real Samsung request -> daemon -> bidirectional sync -> receipt PASS multiple distinct request IDs consumed once each PASS ``` ## Current implementation cursor -No new feature is frozen by this documentation cleanup. +The Android draft correction is implemented, device-validated and reviewed. +No product-roadmap ordering changes were made. ```text MEASURED_MAX_V1=PASS @@ -156,7 +191,7 @@ MEASURED_MAX_ONLY_FROM_MAX_TEST=PASS WORKING_LOAD_PERCENTAGES=PASS ASSISTANCE_DIRECTION_AWARE=PASS ANDROID_MAX_TEST_SESSION=PASS -DESKTOP_TESTS=21/21 PASS +DESKTOP_TESTS=22/22 PASS ``` A measured maximum is derived only from explicit `max_test` sessions. Ordinary @@ -177,7 +212,7 @@ BODY_COMPOSITION_ESTIMATE=PASS BODY_PROPORTION_RATIOS=PASS BODY_SYMMETRY_ANALYTICS=PASS NO_ESTIMATE_PERSISTENCE=PASS -DESKTOP_TESTS=21/21 PASS +DESKTOP_TESTS=22/22 PASS ``` Android remains capture-only for this feature. diff --git a/docs/database.md b/docs/database.md index 75262b5..e875c43 100644 --- a/docs/database.md +++ b/docs/database.md @@ -258,6 +258,27 @@ distance km The Android SQLite database is independent. +Current Android-local version: **4**. The explicit v3 -> v4 migration only adds +structured draft tables; it does not rebuild or delete existing domain tables. + +| Table | Ownership | +| --- | --- | +| `active_session_draft` | Single `id = 1` row, session type, selected catalog row, raw form text, update time | +| `draft_session_exercises` | Ordered draft exercises and profile snapshots | +| `draft_performed_sets` | Ordered heterogeneous repetition or duration actuals | +| `draft_continuous_activity` | Duration and configured speed/distance without synthetic sets | + +Foreign keys remain enabled. Draft deletion cascades only through draft child +tables; it cannot delete catalog entries or completed history. The repository +commits completed-session insertion and draft removal together, rolling back +both on failure. Repeating finalization after success cannot create another +completed session. Completed `started_at` semantics are unchanged by this repair. + +Migration tests use a real v3-shaped fixture. The physical Samsung upgrade also +preserved every existing domain row, with successful integrity and foreign-key +checks. Device backup files are outside the repository; no SQLite files are +used as synchronization artifacts. + Desktop and Android schema versions are not required to match. Do not synchronize SQLite database files. @@ -275,7 +296,7 @@ Migration-specific regression coverage includes: schema_v5_migration ``` -The current normal suite contains 19 tests. +The current normal desktop suite contains 22 tests. ## 11. Measured-max derivation diff --git a/docs/exchange_format.md b/docs/exchange_format.md index d30364d..aea86f1 100644 --- a/docs/exchange_format.md +++ b/docs/exchange_format.md @@ -524,9 +524,11 @@ For each incoming exercise, the TUI reconciles against the local canonical catal Reuse the existing exercise. -If the normalized display name differs, import is allowed but a non-fatal metadata warning is surfaced. - -The canonical local name is not silently changed. +If the normalized display name differs, update `name` and `normalized_name` in +the existing catalog row. This is a stable-ID rename: completed session +references remain attached to the same row and no second exercise is created. +If another identity already owns the incoming normalized name, reject the +import as an identity conflict. ### Same ID, different tracking mode diff --git a/docs/reviews/android_session_draft_v1_resume.md b/docs/reviews/android_session_draft_v1_resume.md new file mode 100644 index 0000000..98a44a8 --- /dev/null +++ b/docs/reviews/android_session_draft_v1_resume.md @@ -0,0 +1,179 @@ +# Android session draft v1 — execution and validation record + +Date: 2026-09-07. **ANDROID_SESSION_DRAFT_V1=PASS.** Implementation, canonical +documentation synchronization, real-device validation and final audit complete. + +## Scope and baseline + +User authorized full execution of `android-session-draft-prompt.md` and selected +GPT-6 Astra, MEDIUM for substantial implementation. Baseline HEAD: `444a4d2`. +Existing Notcurses changes were preserved. No staging, commit, push, reset, +restore, stash, clean, uninstall or package-data clear was performed. + +## Persistence and review + +Android local schema migrated additively from v3 to v4. The singleton +`active_session_draft` and its `draft_session_exercises`, +`draft_performed_sets`, `draft_continuous_activity` children persist workout +and raw form state independently of completed history. Repository mutations +own durable autosave; finalization inserts completed history and clears the +draft transactionally. Frozen exchange artifacts, desktop schema and timestamp +semantics are unchanged. + +The bounded reviewer identified missing selection diagnostics/raw preservation +and unguarded DB-open/transaction-begin failures. Astra repaired these and added +regressions. Subsequent bounded review passed the exact repairs and isolated UI +harness. The missing-selection path clears only the selection, retains every +raw field and added exercise, and returns a specific warning. + +## Real-device results + +Device: Samsung SM_G990B, serial `RFCT10N6ZFP`. +Both APKs were installed with `adb install -r`; existing installation and +user data were preserved. The app remains installed and MainActivity was +launched after validation. + +The first locked-device UI attempt could not find an interactive Compose +hierarchy. After the user unlocked the phone, the same installed UI tests +passed 3/3. Additional cancellation/recreation/idempotency assertions were +built; the final instrumentation APK then passed **5/5** tests (2 repository, +3 UI). No keyguard bypass or show-when-locked flags were used. + +### Production MainActivity matrix + +- Created a draft through the normal UI using the two existing catalog entries: + `marche` = 720 seconds at 5.5 km/h; `Gym/Échauffement` = 420 seconds. +- Home and switching to Android Settings preserved both exact exercises. +- Back showed Home Resume and the two-exercise summary. +- Background `am kill` was verified by absent PID; original PID 20863 exited + and resumed process PID 22891 displayed the exact two-exercise draft. +- Explicit force-stop and cold relaunch restored Home Resume and exact values. +- Rotation landscape/portrait caused real `wm_relaunch_resume_activity` events + for `MainActivity` at 15:16:28 and 15:16:30 device time. Home Resume remained. +- `lifecycle-two-exercises.db` and `after-lifecycle.db` are byte-identical. +- Removed `marche` from the draft, leaving `Gym/Échauffement` at 420 seconds. + Selected `marche` for another unfinished entry, with raw duration `39.` + and speed `6,`. +- After verified background process exit and relaunch, Home Resume restored the + remaining exercise and exact raw fields; the removed exercise was not added + back. Force-stop/relaunch also restored the raw fields. +- `removed-with-raw-form.db` and `removed-after-kill-verified.db` are + byte-identical. Both catalog entries remain present. +- The normal new-session action opened the existing draft without overwriting + it. History still displayed no completed sessions. +- Discard confirmation -> Annuler retained Resume and exact DB bytes + (`after-discard-cancel.db` matches the raw-form snapshot). +- Confirmed discard -> force-stop/relaunch left no Resume, and all four draft + tables had zero rows. +- The actual mobile snapshot pulled while a draft existed contains zero + sessions and the unchanged v1 keys: format, version, generated_at, exercises, + sessions, body_observations. +- The phone's original 30-second screen timeout and free rotation setting were + restored after temporary test configuration changes. + +### Final save and stale-resurrection checks + +On the same physical device, isolated tests render the production HomeScreen +and SessionScreen through debug-only, non-exported `DraftUiTestActivity`. +It uses only `trainlog-draft-ui-test.db` and performs no shared export writes. + +The production final-save control creates exactly one completed session, +clears the draft, and returns Home without Resume. Activity recreation still +shows no Resume and exactly one completed session. A second repository +finalization returns Invalid and does not create another session. Both host +and real-SQLite tests cover this repeated-finalization check. Repository export +contains one completed session after successful finalization. + +This isolation avoids fictitious completed workouts in the user's real history. +No actual user completed session was added/deleted for testing. + +## User-data preservation + +The original v3 database contained two exercises, one body observation, and no +completed sessions or session children. Migration and the entire device matrix +preserved **every original row exactly** across all six original domain tables. +The final database is v4, integrity check is `ok`, foreign-key check is empty, +and every draft table is empty. The comparison helper verifies complete rows, +not merely counts. + +## Test evidence + +- Host `./gradlew test`: 8/8 PASS, no skips/failures/errors. +- `assembleDebug` and `assembleDebugAndroidTest`: PASS. +- Final device `am instrument`: 5/5 PASS. +- Desktop Meson compile and suite: 22/22 PASS. +- Frozen JSON and import-contract validators: PASS. +- `git diff --check`: PASS. +- Entire `tui/` compared with pre-ticket tar snapshot: unchanged by this ticket. + +Host test XML: +`android/app/build/test-results/testDebugUnitTest/TEST-com.labfytools.trainlog.data.TrainlogRepositoryDraftTest.xml`. + +Device command: + +```bash +adb -s RFCT10N6ZFP shell am instrument -w \ + -e package com.labfytools.trainlog \ + com.labfytools.trainlog.test/androidx.test.runner.AndroidJUnitRunner +``` + +## Backup and retained artifacts + +Outside-repository directory: `/tmp/trainlog-android-draft-v1-mD9RFF/`. + +Original quiescent database/preferences backup: `user-data-before.tar`. + +SHA-256: +`3ea2e3b61e921918762a84b88abd82337b7afae25fc2d95ff2420d228fcea980`. + +Also retained: baseline.patch, baseline-tui.tar, original databases/, +after-install.db, final-device.db, lifecycle-two-exercises.db, +after-lifecycle.db, removed-with-raw-form.db, removed-after-kill-verified.db, +after-discard-cancel.db, export-with-draft.json, completed-device-matrix.db, +verify_user_data.py and semantic ADB helper device_ui.py. + +Only `/tmp` was writable outside the repository. The backup must be copied to +permanent storage before temporary-directory cleanup; no DB backup or generated +exchange artifact is in the repository. + +## Final audit and closeout + +The configured Terra-high `android_final_review` performed the single final +audit and returned PASS with no blocking findings. It noted that an intended +post-final-save recreation assertion had landed in the discard test instead. +The final-save test now also recreates the Activity and rechecks absent Resume +before verifying exactly one completed session. The corrected test APK was +installed with `-r`; the entire device suite passed 5/5 again (4.327 seconds). + +The audit also recorded pre-existing production `MainActivity` repository +cleanup as non-blocking resource-lifetime maintenance: the helper is not +explicitly closed on Activity destruction. This was not classified as a new +durability regression or a prerequisite for this corrective checkpoint. + +Final normal validation: desktop22/22, JSON21 cases, import6 cases and diff +check PASS. Android final `test assembleDebug assembleDebugAndroidTest` invocation +succeeded using retained writable tool homes; its76 tasks were up to date with +the actual8/8 host run after finalization assertions were strengthened. After +the last six-line UI-test correction, `assembleDebugAndroidTest` rebuilt +successfully and the on-device suite passed as recorded above. + +Sandbox invocation (from `android/`): + +```bash +GRADLE_USER_HOME=/tmp/trainlog-gradle-user-home \ +ANDROID_USER_HOME=/tmp/trainlog-android-home \ +JAVA_HOME=/usr/lib/jvm/java-17-openjdk \ +./gradlew test assembleDebug assembleDebugAndroidTest +``` + +The default Gradle-home invocation initially failed before executing tasks +because its lock path was read-only; the retained writable homes resolved it. +SDK analytics and Kotlin-daemon cache paths emitted sandbox diagnostics; Kotlin +successfully used its fallback compiler. The generated diagnostic log was moved +outside the repository. No source-warning policy was weakened. + +Canonical docs are synchronized and PASS markers now reflect executed evidence. +The original Notcurses implementation is unchanged by this ticket. User data, +backup, current APKs and full diff/source evidence are retained. No commit or +push was performed. There is no unfinished implementation or device-validation +step for this ticket; retain the backup outside temporary storage as noted above. diff --git a/docs/roadmap.md b/docs/roadmap.md index 18d2049..a50b2df 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -15,7 +15,7 @@ GATE_2=PASS TRAINLOG_FORMAT_V1=FROZEN DESKTOP_SCHEMA_V5=PASS -ANDROID_LOCAL_DATABASE_V3=PASS +ANDROID_LOCAL_DATABASE_V4=PASS DIRECT_MTP_TRANSPORT=PASS BIDIRECTIONAL_SYNC_V1=PASS @@ -23,14 +23,19 @@ BIDIRECTIONAL_SYNC_V1=PASS VARIABLE_REPETITION_SETS=PASS MEASURED_MAX_V1=PASS BODY_ANALYTICS_V1=PASS +EXERCISE_EDIT_V1=PASS +ANDROID_BANNER_PARITY_V1=PASS -DESKTOP_TESTS=21/21 PASS +DESKTOP_TESTS=22/22 PASS +TUI_NOTCURSES_V1=PASS +NCURSESW_REMOVED_FROM_ACTIVE_TUI=PASS +NOTCURSES_TRUECOLOR_THEME=PASS ``` The current product baseline includes: - SQLite persistence and explicit migrations; -- usable ncurses desktop TUI; +- usable Notcurses desktop TUI; - native Android capture client; - exercise catalog; - profile-aware set and continuous activity; @@ -43,8 +48,19 @@ The current product baseline includes: - bidirectional Android/PC synchronization; - shared synchronization engine and `trainlog-syncd`. +`EXERCISE_EDIT_V1` is a completed capture correction: Android permits +stable-ID renames, protects referenced profiles, and reconciles same-ID display +metadata without duplicates. `ANDROID_BANNER_PARITY_V1` is a presentation-only +completed checkpoint: all Android screens share the Notcurses-derived compact +header; it does not reorder the roadmap below. + ## Product boundary +`ANDROID_SESSION_DRAFT_V1` is an implemented P0 capture-reliability correction: +one durable active draft, Home resume, explicit discard and atomic completion. +Host and device validation and the final tranche review pass. This repair +does not introduce planning/templates or reorder the product roadmap below. + The intended split remains: ```text diff --git a/docs/sync_exchange.md b/docs/sync_exchange.md index 29fd141..be758ba 100644 --- a/docs/sync_exchange.md +++ b/docs/sync_exchange.md @@ -103,6 +103,13 @@ continuous No synthetic set is created for continuous work. +Android-local active-session drafts are excluded from this snapshot and remain +local during synchronization. Only successful atomic finalization makes a draft +a completed exportable session. The v1 artifact has no draft fields or tables; +catalog reconciliation preserves active draft references. +Same-ID catalog entries may update display-name metadata in their existing +catalog row; a rename never creates a second exercise identity. + ## 5. Desktop mobile importer Reference importer: diff --git a/docs/tests.md b/docs/tests.md index 7cb95e7..8e8fec2 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -79,15 +79,23 @@ Current normal suite: 16 usb 17 variable_sets 18 schema_v5_migration -19 mobile_import_variable_sets +19 measured_max +20 body_analytics +21 terminal_input_event_type_policy +22 mobile_import_variable_sets ``` Validated checkpoint: ```text -21/21 PASS +22/22 PASS ``` +The desktop executable is additionally smoke-checked in isolated tmux PTYs at +100x30, the exact 72x20 minimum, and the 60x15 fallback; a resize down/up must +recover before a clean keyboard quit. Notcurses is verified as the executable's +direct terminal dependency with `readelf -d`. + Notable regression coverage: - transactional persisted-session replacement; @@ -98,8 +106,12 @@ Notable regression coverage: - repetition shorthand/list/pyramid parsing; - direct v4 -> v5 database migration; - heterogeneous mobile-set import; +- Notcurses input lifecycle translation: PRESS/REPEAT are actionable while a + RELEASE event is consumed without creating a second navigation action. - targetless mobile SETS persistence; - mobile-import idempotence. +- stable-ID mobile-to-desktop rename reconciliation without duplicate catalog + rows or historical-reference replacement. ## 5. Build @@ -130,6 +142,14 @@ Install to the connected device when hardware behavior changes: adb install -r app/build/outputs/apk/debug/app-debug.apk ``` +Android repository host tests additionally cover exercise editing: + +- trimmed rename preserves `exercise_id` and recalculates `normalized_name`; +- duplicate and invalid names are rejected; +- completed history and active-draft references resolve the renamed catalog row; +- a referenced profile change is explicitly rejected; +- same-ID PC-catalog rename reconciles in place without a duplicate. + ## 7. Hardware MTP validation Hardware probes and real synchronization are separate from the normal automated @@ -238,7 +258,7 @@ Coverage proves: Current normal baseline: ```text -21/21 PASS +22/22 PASS ``` ## 12. Body analytics regression @@ -263,5 +283,53 @@ Coverage includes: Current normal baseline: ```text -21/21 PASS +22/22 PASS ``` + +## 13. Android session draft v1 + +Android schema v4 adds one durable active draft with an explicit additive v3 -> +v4 migration. The current host suite has **8 tests**, covering all exercise +shapes and raw partial text, fresh repository restore, remove/discard, atomic +finalization and repeated-finalize rejection, rollback, catalog reconciliation, +missing-selection recovery, explicit DB-open failure and historical migration. + +```bash +cd android +JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew test +JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew assembleDebug assembleDebugAndroidTest +adb install -r app/build/outputs/apk/debug/app-debug.apk +adb install -r app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk +adb shell am instrument -w -e package com.labfytools.trainlog \ + com.labfytools.trainlog.test/androidx.test.runner.AndroidJUnitRunner +``` + +The device instrumentation suite has **5 tests**: two real-SQLite repository +checks and three production-screen Compose UI checks. Coverage includes exact +raw form restoration through Activity recreation, cancellation and confirmation +of discard, final save with no stale Resume after recreation, and refusal of a +second completion. All tests use isolated databases; the debug-only, +non-exported Activity renders production Home/Session screens without exporting +synthetic data. The phone must be unlocked and interactive. Do not interpret a +locked-screen `No compose hierarchies found` failure as a passing UI check. + +The Samsung SM_G990B additionally passed the normal `MainActivity` matrix: + +- two real catalog exercises with distinct continuous values survived Home, + another app, background `am kill` with verified PID exit, and force-stop; +- rotation recreated the Activity and Home Resume restored both exercises; +- removal of one exercise survived process death, with the other intact; +- raw duration/speed text survived process death and force-stop exactly; +- Back and the normal new-session action preserved the existing draft; +- discard cancellation preserved the DB exactly; confirmation and relaunch + left all draft tables empty and no Resume action; +- completed History and mobile export excluded the populated draft; +- every original domain row survived migration and the entire device matrix. + +Final-save UI and repeated-finalization tests ran on-device with isolated data; +no fictitious completed session was added to the user's history. Real migration +and final state passed SQLite integrity/foreign-key checks. The pre-upgrade DB +and preferences backup is outside the repository. No uninstall, package-data +clear, desktop schema change or frozen artifact change is part of this repair. + +Detailed retained evidence: [Android draft execution record](reviews/android_session_draft_v1_resume.md). diff --git a/docs/tui.md b/docs/tui.md index 7f32e48..1e67a8a 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -2,7 +2,7 @@ ## 1. Purpose -The Trainlog desktop application is a C17/ncursesw interface for durable +The Trainlog desktop application is a C17/Notcurses interface for durable history, correction, analysis, visualization, direct data entry, and manual synchronization. @@ -46,6 +46,22 @@ Smaller terminals display a clear fallback instead of corrupt layout. The TUI uses centralized semantic theme roles. +Notcurses provides a true-color Catppuccin-derived dark palette: background +`#1E1E2E`, surface `#181825`, text `#CDD6F4`, and semantic accent, success, +warning, error, muted, and graph roles. Unicode frames and visible selection +markers enhance presentation without becoming application semantics. + +The backend owns one standard plane for a run, translates terminal input into +Trainlog-owned keys, accepts complete UTF-8 code points in prompts, and +re-queries dimensions while rendering so the 72x20 minimum/fallback recovers +after a resize. + +Input lifecycle is handled at that boundary: legacy/unknown terminal events, +Notcurses PRESS events, and deliberate auto-REPEAT events become one logical +Trainlog action; Notcurses RELEASE events are consumed and never reach screen +navigation or prompt handling. This prevents extended terminal keyboard +protocols from applying one physical keypress twice. + Color is not the sole state carrier. Typical roles: diff --git a/tests/test_mobile_import_variable_sets.py b/tests/test_mobile_import_variable_sets.py index 2b85843..bf75054 100755 --- a/tests/test_mobile_import_variable_sets.py +++ b/tests/test_mobile_import_variable_sets.py @@ -265,6 +265,19 @@ def main() -> int: + second ) + renamed_payload = payload() + renamed_payload["exercises"][0]["name"] = "Pompes corrigées" + renamed_payload["sessions"][0]["exercises"][0]["name"] = "Pompes corrigées" + json_path.write_text( + json.dumps(renamed_payload, ensure_ascii=False), + encoding="utf-8", + ) + renamed = run_import(json_path, database_path) + if "exercises_reconciled=1" not in renamed: + raise AssertionError( + "stable-ID rename was not reconciled:\n" + renamed + ) + connection = sqlite3.connect( database_path ) @@ -317,6 +330,23 @@ def main() -> int: raise AssertionError( f"fake target persisted: {target!r}" ) + + catalog = connection.execute( + """ + SELECT exercise_id, name, normalized_name + FROM exercises; + """ + ).fetchall() + if catalog != [ + ( + "ex_mobile_pyramid", + "Pompes corrigées", + "pompes corrigées", + ) + ]: + raise AssertionError( + f"stable-ID rename created or lost catalog row: {catalog!r}" + ) finally: connection.close() diff --git a/tools/import_mobile_export.py b/tools/import_mobile_export.py index 593d083..cec6a6e 100755 --- a/tools/import_mobile_export.py +++ b/tools/import_mobile_export.py @@ -736,11 +736,45 @@ def import_exercises( f"profil incompatible pour {exercise_id}" ) + by_name = lookup_exercise_by_normalized( + connection, + normalized, + ) + if ( + by_name is not None + and by_name["id"] != by_id["id"] + ): + raise ImportFailure( + "conflit de nom pour l'identité " + + exercise_id + ) + + # CONTRACT: exercise_id is the synchronization identity. A rename + # updates metadata in place, retaining every historical and draft + # foreign-key reference instead of creating a second exercise. + if ( + by_id["name"] != exercise["name"] + or by_id["normalized_name"] != normalized + ): + connection.execute( + """ + UPDATE exercises + SET name = ?, normalized_name = ? + WHERE id = ?; + """, + ( + exercise["name"], + normalized, + by_id["id"], + ), + ) + report["exercises_reconciled"] += 1 + else: + report["exercises_skipped"] += 1 + mapping[exercise_id] = ( by_id["exercise_id"] ) - - report["exercises_skipped"] += 1 continue by_name = lookup_exercise_by_normalized( diff --git a/tui/include/trainlog/terminal.h b/tui/include/trainlog/terminal.h new file mode 100644 index 0000000..0804229 --- /dev/null +++ b/tui/include/trainlog/terminal.h @@ -0,0 +1,93 @@ +#ifndef TRAINLOG_TERMINAL_H +#define TRAINLOG_TERMINAL_H + +/** + * @file terminal.h + * @brief Small explicit Notcurses terminal boundary for the desktop TUI. + */ + +#include +#include +#include + +#include "trainlog/theme.h" + +typedef struct TrainlogTerminal TrainlogTerminal; +typedef struct TrainlogPanel TrainlogPanel; + +typedef enum TrainlogKey { + TRAINLOG_KEY_NONE = -1, + TRAINLOG_KEY_UP = -1001, + TRAINLOG_KEY_DOWN, + TRAINLOG_KEY_LEFT, + TRAINLOG_KEY_RIGHT, + TRAINLOG_KEY_ENTER, + TRAINLOG_KEY_TAB, + TRAINLOG_KEY_ESCAPE, + TRAINLOG_KEY_BACKSPACE, + TRAINLOG_KEY_DELETE, + TRAINLOG_KEY_HOME, + TRAINLOG_KEY_END, + TRAINLOG_KEY_PAGE_UP, + TRAINLOG_KEY_PAGE_DOWN, + TRAINLOG_KEY_RESIZE, + TRAINLOG_KEY_F1, + TRAINLOG_KEY_F2, + TRAINLOG_KEY_F3, + TRAINLOG_KEY_F4, + TRAINLOG_KEY_F5, + TRAINLOG_KEY_SHIFT_TAB +} TrainlogKey; + +/* Trainlog-owned input lifecycle. UNKNOWN is the legacy terminal event form; + * PRESS and REPEAT are actionable, while RELEASE is never a user action. */ +typedef enum TrainlogInputEventType { + TRAINLOG_INPUT_UNKNOWN = 0, + TRAINLOG_INPUT_PRESS, + TRAINLOG_INPUT_REPEAT, + TRAINLOG_INPUT_RELEASE +} TrainlogInputEventType; + +/* WHY: a run owns its terminal so cleanup is deterministic and no application + * terminal state leaks across a later run in the same process. */ +TrainlogTerminal *trainlog_terminal_create(void); +void trainlog_terminal_destroy(TrainlogTerminal *terminal); +int trainlog_terminal_rows(const TrainlogTerminal *terminal); +int trainlog_terminal_columns(const TrainlogTerminal *terminal); +void trainlog_terminal_erase(TrainlogTerminal *terminal); +void trainlog_terminal_render(TrainlogTerminal *terminal); +void trainlog_terminal_style_on(TrainlogTerminal *terminal, TrainlogTextStyle style); +void trainlog_terminal_style_off(TrainlogTerminal *terminal, TrainlogTextStyle style); +void trainlog_terminal_printf(TrainlogTerminal *terminal, int row, int column, + const char *format, ...) + __attribute__((format(printf, 4, 5))); +void trainlog_terminal_putn(TrainlogTerminal *terminal, const char *text, size_t length); +void trainlog_terminal_move(TrainlogTerminal *terminal, int row, int column); +void trainlog_terminal_cursor_yx(const TrainlogTerminal *terminal, int *row, int *column); +void trainlog_terminal_clear_to_end(TrainlogTerminal *terminal); +void trainlog_terminal_cursor_visible(TrainlogTerminal *terminal, bool visible); +void trainlog_terminal_draw(TrainlogTerminal *terminal, int row, int column, uint32_t codepoint); +void trainlog_terminal_box(TrainlogTerminal *terminal, int top, int left, + int bottom, int right); +bool trainlog_terminal_translate_input(uint32_t id, + TrainlogInputEventType event_type, + bool shifted, + int *key); +int trainlog_terminal_get_key(TrainlogTerminal *terminal); +bool trainlog_terminal_read_unicode(TrainlogTerminal *terminal, int *codepoint, + char utf8[5]); +bool trainlog_terminal_push_key(TrainlogTerminal *terminal, int key); + +/* Private screen-port helpers. Panels are lightweight coordinate views over + * the one standard plane, not independently owned terminal surfaces. */ +TrainlogPanel *tui_panel_create(TrainlogTerminal *terminal, int height, int width, + int top, int left); +void tui_panel_destroy(TrainlogPanel *panel); +void tui_panel_box(TrainlogPanel *panel); +void tui_panel_style_on(TrainlogPanel *panel, TrainlogTextStyle style); +void tui_panel_style_off(TrainlogPanel *panel, TrainlogTextStyle style); +void tui_panel_print(TrainlogPanel *panel, int row, int column, const char *format, ...) + __attribute__((format(printf, 4, 5))); +void tui_panel_commit(TrainlogPanel *panel); + +#endif diff --git a/tui/include/trainlog/theme.h b/tui/include/trainlog/theme.h index 9add527..06f5dca 100644 --- a/tui/include/trainlog/theme.h +++ b/tui/include/trainlog/theme.h @@ -1,11 +1,11 @@ #ifndef TRAINLOG_THEME_H #define TRAINLOG_THEME_H -#include +#include /** * @file theme.h - * @brief Centralized ncurses color roles for the Trainlog TUI. + * @brief Centralized true-color semantic roles for the Trainlog TUI. */ typedef enum TrainlogColorRole { @@ -18,7 +18,16 @@ typedef enum TrainlogColorRole { TRAINLOG_COLOR_GRAPH = 6 } TrainlogColorRole; -void trainlog_theme_initialize(void); -attr_t trainlog_theme_attribute(TrainlogColorRole role); +/* + * CONTRACT: styles are terminal-library-independent semantic values. Screen + * code never owns a palette index or an ncurses attribute. + */ +typedef uint32_t TrainlogTextStyle; + +#define TRAINLOG_TEXT_NORMAL ((TrainlogTextStyle)0U) +#define TRAINLOG_TEXT_BOLD ((TrainlogTextStyle)0x0001U) +#define TRAINLOG_TEXT_REVERSE ((TrainlogTextStyle)0x0002U) + +TrainlogTextStyle trainlog_theme_style(TrainlogColorRole role); #endif diff --git a/tui/include/trainlog/tui.h b/tui/include/trainlog/tui.h index 7cdfd4e..2a65ed3 100644 --- a/tui/include/trainlog/tui.h +++ b/tui/include/trainlog/tui.h @@ -3,7 +3,7 @@ /** * @file tui.h - * @brief Interactive ncurses entry point. + * @brief Interactive Notcurses entry point. */ #include "trainlog/database.h" diff --git a/tui/meson.build b/tui/meson.build index 3ac2209..40666f6 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -5,10 +5,7 @@ uuid_dep = dependency('uuid', required: true) udev_dep = dependency('libudev', required: true) mtp_dep = dependency('libmtp', required: true) -ncursesw_dep = dependency('ncursesw', required: false) -if not ncursesw_dep.found() - ncursesw_dep = cc.find_library('ncursesw', required: true) -endif +notcurses_dep = dependency('notcurses-core', required: true) utf8proc_dep = dependency('libutf8proc', required: false) if not utf8proc_dep.found() @@ -71,6 +68,7 @@ trainlog_core_dep = declare_dependency( trainlog_tui_sources = files( 'src/main.c', + 'src/terminal.c', 'src/theme.c', 'src/tui.c', ) @@ -80,7 +78,7 @@ trainlog_exe = executable( trainlog_tui_sources, dependencies: [ trainlog_core_dep, - ncursesw_dep, + notcurses_dep, ], c_args: strict_c_args, install: true, @@ -367,3 +365,17 @@ test( 'body_analytics', test_body_analytics, ) + +test_terminal_input = executable( + 'test_terminal_input', + 'tests/test_terminal_input.c', + 'src/terminal.c', + include_directories: trainlog_include, + dependencies: notcurses_dep, + c_args: strict_c_args, +) + +test( + 'terminal_input_event_type_policy', + test_terminal_input, +) diff --git a/tui/src/terminal.c b/tui/src/terminal.c new file mode 100644 index 0000000..56f03d1 --- /dev/null +++ b/tui/src/terminal.c @@ -0,0 +1,419 @@ +/** + * @file terminal.c + * @brief Explicit, bounded Notcurses adapter used only by the desktop TUI. + */ + +#include "trainlog/terminal.h" + +#include +#include +#include +#include + +#include + +struct TrainlogTerminal { + struct notcurses *notcurses; + struct ncplane *plane; + TrainlogTextStyle style; + int pushed_key; +}; + +struct TrainlogPanel { + TrainlogTerminal *terminal; + int height; + int width; + int top; + int left; +}; + +static void terminal_apply_style(TrainlogTerminal *terminal) +{ + unsigned red = 205U; + unsigned green = 214U; + unsigned blue = 244U; + TrainlogColorRole role; + + if (terminal == NULL || terminal->plane == NULL) { + return; + } + + role = (TrainlogColorRole)((terminal->style >> 8U) & 0xffU); + switch (role) { + case TRAINLOG_COLOR_ACCENT: red = 148U; green = 226U; blue = 213U; break; + case TRAINLOG_COLOR_SUCCESS: red = 166U; green = 227U; blue = 161U; break; + case TRAINLOG_COLOR_WARNING: red = 249U; green = 226U; blue = 175U; break; + case TRAINLOG_COLOR_ERROR: red = 243U; green = 139U; blue = 168U; break; + case TRAINLOG_COLOR_MUTED: red = 137U; green = 180U; blue = 250U; break; + case TRAINLOG_COLOR_GRAPH: red = 245U; green = 194U; blue = 231U; break; + case TRAINLOG_COLOR_DEFAULT: + default: break; + } + (void)ncplane_set_fg_rgb8(terminal->plane, red, green, blue); + /* CONTRACT: selection remains visible without relying only on foreground + * color. A role-aware surface fill survives terminals with weak color + * contrast while ordinary drawing uses the canonical dark background. */ + if ((terminal->style & TRAINLOG_TEXT_REVERSE) != 0U) { + (void)ncplane_set_bg_rgb8(terminal->plane, 49U, 50U, 68U); + } else { + (void)ncplane_set_bg_rgb8(terminal->plane, 30U, 30U, 46U); + } + ncplane_set_styles(terminal->plane, + (terminal->style & TRAINLOG_TEXT_BOLD) != 0U ? NCSTYLE_BOLD : 0U); +} + +TrainlogTerminal *trainlog_terminal_create(void) +{ + notcurses_options options = {0}; + TrainlogTerminal *terminal = calloc(1U, sizeof(*terminal)); + + if (terminal == NULL) { + return NULL; + } + options.flags = NCOPTION_SUPPRESS_BANNERS; + terminal->notcurses = notcurses_core_init(&options, NULL); + if (terminal->notcurses == NULL) { + free(terminal); + return NULL; + } + terminal->plane = notcurses_stdplane(terminal->notcurses); + if (terminal->plane == NULL) { + (void)notcurses_stop(terminal->notcurses); + free(terminal); + return NULL; + } + (void)ncplane_set_bg_rgb8(terminal->plane, 30U, 30U, 46U); + terminal->pushed_key = TRAINLOG_KEY_NONE; + terminal_apply_style(terminal); + return terminal; +} + +void trainlog_terminal_destroy(TrainlogTerminal *terminal) +{ + if (terminal == NULL) { + return; + } + if (terminal->notcurses != NULL) { + (void)notcurses_stop(terminal->notcurses); + } + free(terminal); +} + +int trainlog_terminal_rows(const TrainlogTerminal *terminal) +{ + unsigned rows = 0U; + if (terminal != NULL && terminal->plane != NULL) { + ncplane_dim_yx(terminal->plane, &rows, NULL); + } + return rows <= (unsigned)INT_MAX ? (int)rows : 0; +} + +int trainlog_terminal_columns(const TrainlogTerminal *terminal) +{ + unsigned columns = 0U; + if (terminal != NULL && terminal->plane != NULL) { + ncplane_dim_yx(terminal->plane, NULL, &columns); + } + return columns <= (unsigned)INT_MAX ? (int)columns : 0; +} + +void trainlog_terminal_erase(TrainlogTerminal *terminal) +{ + if (terminal != NULL && terminal->plane != NULL) { + ncplane_erase(terminal->plane); + terminal_apply_style(terminal); + } +} + +void trainlog_terminal_render(TrainlogTerminal *terminal) +{ + if (terminal != NULL && terminal->notcurses != NULL) { + (void)notcurses_render(terminal->notcurses); + } +} + +void trainlog_terminal_style_on(TrainlogTerminal *terminal, TrainlogTextStyle style) +{ + if (terminal != NULL) { + terminal->style |= style; + terminal_apply_style(terminal); + } +} + +void trainlog_terminal_style_off(TrainlogTerminal *terminal, TrainlogTextStyle style) +{ + if (terminal != NULL) { + terminal->style &= ~style; + terminal_apply_style(terminal); + } +} + +void trainlog_terminal_printf(TrainlogTerminal *terminal, int row, int column, + const char *format, ...) +{ + va_list arguments; + va_list copy; + int count; + char *text; + + if (terminal == NULL || terminal->plane == NULL || format == NULL || + row < 0 || column < 0 || row >= trainlog_terminal_rows(terminal) || + column >= trainlog_terminal_columns(terminal)) { + return; + } + va_start(arguments, format); + va_copy(copy, arguments); + count = vsnprintf(NULL, 0U, format, copy); + va_end(copy); + if (count < 0 || (size_t)count > SIZE_MAX - 1U) { + va_end(arguments); + return; + } + text = malloc((size_t)count + 1U); + if (text != NULL) { + (void)vsnprintf(text, (size_t)count + 1U, format, arguments); + (void)ncplane_putstr_yx(terminal->plane, row, column, text); + free(text); + } + va_end(arguments); +} + +void trainlog_terminal_putn(TrainlogTerminal *terminal, const char *text, size_t length) +{ + if (terminal == NULL || terminal->plane == NULL || text == NULL || length > (size_t)INT_MAX) { + return; + } + (void)ncplane_putnstr(terminal->plane, length, text); +} + +void trainlog_terminal_move(TrainlogTerminal *terminal, int row, int column) +{ + if (terminal != NULL && terminal->plane != NULL && row >= 0 && column >= 0) { + (void)ncplane_cursor_move_yx(terminal->plane, row, column); + } +} + +void trainlog_terminal_cursor_yx(const TrainlogTerminal *terminal, int *row, int *column) +{ + unsigned y = 0U; + unsigned x = 0U; + if (terminal != NULL && terminal->plane != NULL) { + ncplane_cursor_yx(terminal->plane, &y, &x); + } + if (row != NULL) { *row = y <= (unsigned)INT_MAX ? (int)y : 0; } + if (column != NULL) { *column = x <= (unsigned)INT_MAX ? (int)x : 0; } +} + +void trainlog_terminal_clear_to_end(TrainlogTerminal *terminal) +{ + if (terminal != NULL && terminal->plane != NULL) { + (void)ncplane_erase_region(terminal->plane, -1, -1, 0, INT_MAX); + } +} + +void trainlog_terminal_cursor_visible(TrainlogTerminal *terminal, bool visible) +{ + if (terminal == NULL || terminal->notcurses == NULL) { return; } + if (visible) { + (void)notcurses_cursor_enable(terminal->notcurses, -1, -1); + } else { + (void)notcurses_cursor_disable(terminal->notcurses); + } +} + +void trainlog_terminal_draw(TrainlogTerminal *terminal, int row, int column, uint32_t codepoint) +{ + if (terminal != NULL && terminal->plane != NULL && row >= 0 && column >= 0 && + row < trainlog_terminal_rows(terminal) && column < trainlog_terminal_columns(terminal)) { + nccell cell = NCCELL_TRIVIAL_INITIALIZER; + /* Notcurses returns the UTF-8 byte count here (not zero) on success. + * Keeping every non-negative result is essential for Unicode frames. */ + if (nccell_load_ucs32(terminal->plane, &cell, codepoint) >= 0) { + (void)ncplane_putc_yx(terminal->plane, row, column, &cell); + nccell_release(terminal->plane, &cell); + } + } +} + +void trainlog_terminal_box(TrainlogTerminal *terminal, int top, int left, int bottom, int right) +{ + int column; + int row; + if (terminal == NULL || top < 0 || left < 0 || bottom <= top || right <= left) { return; } + for (column = left + 1; column < right; ++column) { + trainlog_terminal_draw(terminal, top, column, 0x2500U); + trainlog_terminal_draw(terminal, bottom, column, 0x2500U); + } + for (row = top + 1; row < bottom; ++row) { + trainlog_terminal_draw(terminal, row, left, 0x2502U); + trainlog_terminal_draw(terminal, row, right, 0x2502U); + } + trainlog_terminal_draw(terminal, top, left, 0x250cU); + trainlog_terminal_draw(terminal, top, right, 0x2510U); + trainlog_terminal_draw(terminal, bottom, left, 0x2514U); + trainlog_terminal_draw(terminal, bottom, right, 0x2518U); +} + +static int terminal_key(uint32_t id, bool shifted) +{ + if (id == NCKEY_TAB && shifted) { + return TRAINLOG_KEY_SHIFT_TAB; + } + + switch (id) { + case NCKEY_UP: return TRAINLOG_KEY_UP; case NCKEY_DOWN: return TRAINLOG_KEY_DOWN; + case NCKEY_LEFT: return TRAINLOG_KEY_LEFT; case NCKEY_RIGHT: return TRAINLOG_KEY_RIGHT; + case NCKEY_ENTER: return TRAINLOG_KEY_ENTER; case NCKEY_TAB: return TRAINLOG_KEY_TAB; + case NCKEY_BACKSPACE: return TRAINLOG_KEY_BACKSPACE; + case NCKEY_DEL: return TRAINLOG_KEY_DELETE; case NCKEY_HOME: return TRAINLOG_KEY_HOME; + case NCKEY_END: return TRAINLOG_KEY_END; case NCKEY_PGUP: return TRAINLOG_KEY_PAGE_UP; + case NCKEY_PGDOWN: return TRAINLOG_KEY_PAGE_DOWN; case NCKEY_RESIZE: return TRAINLOG_KEY_RESIZE; + case NCKEY_F01: return TRAINLOG_KEY_F1; case NCKEY_F02: return TRAINLOG_KEY_F2; + case NCKEY_F03: return TRAINLOG_KEY_F3; case NCKEY_F04: return TRAINLOG_KEY_F4; + case NCKEY_F05: return TRAINLOG_KEY_F5; default: return (int)id; + } +} + +bool trainlog_terminal_translate_input(uint32_t id, + TrainlogInputEventType event_type, + bool shifted, + int *key) +{ + if (key == NULL || id == 0U || id == UINT32_MAX) { + return false; + } + + switch (event_type) { + case TRAINLOG_INPUT_UNKNOWN: + case TRAINLOG_INPUT_PRESS: + case TRAINLOG_INPUT_REPEAT: + *key = terminal_key(id, shifted); + return true; + case TRAINLOG_INPUT_RELEASE: + default: + return false; + } +} + +static TrainlogInputEventType terminal_event_type(ncintype_e event_type) +{ + switch (event_type) { + case NCTYPE_UNKNOWN: return TRAINLOG_INPUT_UNKNOWN; + case NCTYPE_PRESS: return TRAINLOG_INPUT_PRESS; + case NCTYPE_REPEAT: return TRAINLOG_INPUT_REPEAT; + case NCTYPE_RELEASE: return TRAINLOG_INPUT_RELEASE; + default: return TRAINLOG_INPUT_RELEASE; + } +} + +static bool terminal_read_input(TrainlogTerminal *terminal, + int *key, + char utf8[5]) +{ + ncinput input; + + if (terminal == NULL || terminal->notcurses == NULL || key == NULL) { + return false; + } + + for (;;) { + uint32_t id = notcurses_get_blocking(terminal->notcurses, &input); + + if (id == 0U || id == UINT32_MAX) { + return false; + } + if (!trainlog_terminal_translate_input(id, + terminal_event_type(input.evtype), + ncinput_shift_p(&input), + key)) { + continue; + } + if (utf8 != NULL) { + (void)snprintf(utf8, 5U, "%s", input.utf8); + } + return true; + } +} + +int trainlog_terminal_get_key(TrainlogTerminal *terminal) +{ + int key; + if (terminal == NULL || terminal->notcurses == NULL) { return TRAINLOG_KEY_NONE; } + if (terminal->pushed_key != TRAINLOG_KEY_NONE) { + int pushed_key = terminal->pushed_key; + terminal->pushed_key = TRAINLOG_KEY_NONE; + return pushed_key; + } + return terminal_read_input(terminal, &key, NULL) ? key : TRAINLOG_KEY_NONE; +} + +bool trainlog_terminal_read_unicode(TrainlogTerminal *terminal, int *codepoint, char utf8[5]) +{ + if (terminal == NULL || terminal->notcurses == NULL || codepoint == NULL || utf8 == NULL) { return false; } + return terminal_read_input(terminal, codepoint, utf8); +} + +bool trainlog_terminal_push_key(TrainlogTerminal *terminal, int key) +{ + if (terminal == NULL || terminal->pushed_key != TRAINLOG_KEY_NONE) { + return false; + } + terminal->pushed_key = key; + return true; +} + +TrainlogPanel *tui_panel_create(TrainlogTerminal *terminal, int height, int width, + int top, int left) +{ + TrainlogPanel *panel; + if (terminal == NULL || height < 2 || width < 2 || top < 0 || left < 0 || + top > trainlog_terminal_rows(terminal) - height || + left > trainlog_terminal_columns(terminal) - width) { + return NULL; + } + panel = malloc(sizeof(*panel)); + if (panel != NULL) { + *panel = (TrainlogPanel){ terminal, height, width, top, left }; + } + return panel; +} + +void tui_panel_destroy(TrainlogPanel *panel) { free(panel); } + +void tui_panel_box(TrainlogPanel *panel) +{ + if (panel != NULL) { + trainlog_terminal_box(panel->terminal, panel->top, panel->left, + panel->top + panel->height - 1, + panel->left + panel->width - 1); + } +} + +void tui_panel_style_on(TrainlogPanel *panel, TrainlogTextStyle style) +{ if (panel != NULL) { trainlog_terminal_style_on(panel->terminal, style); } } + +void tui_panel_style_off(TrainlogPanel *panel, TrainlogTextStyle style) +{ if (panel != NULL) { trainlog_terminal_style_off(panel->terminal, style); } } + +void tui_panel_print(TrainlogPanel *panel, int row, int column, const char *format, ...) +{ + va_list arguments; + va_list copy; + int count; + char *text; + if (panel == NULL || format == NULL || row < 0 || column < 0 || row >= panel->height || column >= panel->width) { return; } + va_start(arguments, format); + va_copy(copy, arguments); + count = vsnprintf(NULL, 0U, format, copy); + va_end(copy); + if (count < 0) { va_end(arguments); return; } + text = malloc((size_t)count + 1U); + if (text != NULL) { + (void)vsnprintf(text, (size_t)count + 1U, format, arguments); + trainlog_terminal_printf(panel->terminal, panel->top + row, panel->left + column, "%s", text); + free(text); + } + va_end(arguments); +} + +void tui_panel_commit(TrainlogPanel *panel) { (void)panel; } diff --git a/tui/src/theme.c b/tui/src/theme.c index e2f295a..c9440fa 100644 --- a/tui/src/theme.c +++ b/tui/src/theme.c @@ -1,34 +1,11 @@ /** * @file theme.c - * @brief Centralized Trainlog ncurses colors. + * @brief Centralized Trainlog semantic text styles. */ #include "trainlog/theme.h" -#include - -void trainlog_theme_initialize(void) +TrainlogTextStyle trainlog_theme_style(TrainlogColorRole role) { - if (!has_colors()) { - return; - } - - start_color(); - use_default_colors(); - - init_pair(TRAINLOG_COLOR_ACCENT, COLOR_CYAN, -1); - init_pair(TRAINLOG_COLOR_SUCCESS, COLOR_GREEN, -1); - init_pair(TRAINLOG_COLOR_WARNING, COLOR_YELLOW, -1); - init_pair(TRAINLOG_COLOR_ERROR, COLOR_RED, -1); - init_pair(TRAINLOG_COLOR_MUTED, COLOR_BLUE, -1); - init_pair(TRAINLOG_COLOR_GRAPH, COLOR_MAGENTA, -1); -} - -attr_t trainlog_theme_attribute(TrainlogColorRole role) -{ - if (!has_colors() || role == TRAINLOG_COLOR_DEFAULT) { - return A_NORMAL; - } - - return COLOR_PAIR((short)role); + return ((TrainlogTextStyle)role << 8U); } diff --git a/tui/src/tui.c b/tui/src/tui.c index bbdecb4..5289203 100644 --- a/tui/src/tui.c +++ b/tui/src/tui.c @@ -19,7 +19,7 @@ #include #include -#include +#include "trainlog/terminal.h" #include "trainlog/bodyviz.h" #include "trainlog/body_analytics.h" @@ -42,6 +42,14 @@ #define MAX_BODY_METRIC_POINTS 256U +/* + * INVARIANT: this pointer is assigned only for the dynamic extent of one + * trainlog_tui_run() call and is cleared before Notcurses shutdown. The + * screen implementation predates explicit context parameters; the terminal + * object itself remains owned by the public run entry point. + */ +static TrainlogTerminal *tui_terminal; + /* TRAINLOG_TUI_V02_POLISH */ /* TRAINLOG_TUI_PROFILED_EXERCISE_CREATION */ @@ -117,32 +125,32 @@ static void session_history_datetime( static void draw_shell(const char *heading, const char *footer) { - erase(); - box(stdscr, 0, 0); + trainlog_terminal_erase(tui_terminal); + trainlog_terminal_box(tui_terminal, 0, 0, trainlog_terminal_rows(tui_terminal) - 1, trainlog_terminal_columns(tui_terminal) - 1); - attron(A_BOLD | trainlog_theme_attribute(TRAINLOG_COLOR_ACCENT)); - mvprintw(1, 2, " %s ", heading); - attroff(A_BOLD | trainlog_theme_attribute(TRAINLOG_COLOR_ACCENT)); + trainlog_terminal_style_on(tui_terminal, TRAINLOG_TEXT_BOLD | trainlog_theme_style(TRAINLOG_COLOR_ACCENT)); + trainlog_terminal_printf(tui_terminal, 1, 2, " %s ", heading); + trainlog_terminal_style_off(tui_terminal, TRAINLOG_TEXT_BOLD | trainlog_theme_style(TRAINLOG_COLOR_ACCENT)); - attron(trainlog_theme_attribute(TRAINLOG_COLOR_MUTED)); - mvprintw(LINES - 2, 2, "%-*s", COLS - 4, footer); - attroff(trainlog_theme_attribute(TRAINLOG_COLOR_MUTED)); + trainlog_terminal_style_on(tui_terminal, trainlog_theme_style(TRAINLOG_COLOR_MUTED)); + trainlog_terminal_printf(tui_terminal, trainlog_terminal_rows(tui_terminal) - 2, 2, "%-*s", trainlog_terminal_columns(tui_terminal) - 4, footer); + trainlog_terminal_style_off(tui_terminal, trainlog_theme_style(TRAINLOG_COLOR_MUTED)); } static void wait_key(void) { - attron(trainlog_theme_attribute(TRAINLOG_COLOR_MUTED)); - mvprintw(LINES - 2, 2, "Appuyez sur une touche pour continuer..."); - attroff(trainlog_theme_attribute(TRAINLOG_COLOR_MUTED)); - refresh(); - (void)getch(); + trainlog_terminal_style_on(tui_terminal, trainlog_theme_style(TRAINLOG_COLOR_MUTED)); + trainlog_terminal_printf(tui_terminal, trainlog_terminal_rows(tui_terminal) - 2, 2, "Appuyez sur une touche pour continuer..."); + trainlog_terminal_style_off(tui_terminal, trainlog_theme_style(TRAINLOG_COLOR_MUTED)); + trainlog_terminal_render(tui_terminal); + (void)trainlog_terminal_get_key(tui_terminal); } static void status_line(const char *text, TrainlogColorRole role) { - attron(trainlog_theme_attribute(role)); - mvprintw(LINES - 3, 2, "%-*s", COLS - 4, text); - attroff(trainlog_theme_attribute(role)); + trainlog_terminal_style_on(tui_terminal, trainlog_theme_style(role)); + trainlog_terminal_printf(tui_terminal, trainlog_terminal_rows(tui_terminal) - 3, 2, "%-*s", trainlog_terminal_columns(tui_terminal) - 4, text); + trainlog_terminal_style_off(tui_terminal, trainlog_theme_style(role)); } static bool prompt_text( @@ -166,55 +174,48 @@ static bool prompt_text( output[0] = '\0'; - mvprintw( + trainlog_terminal_printf(tui_terminal, row, 2, "%s", label ); - getyx( - stdscr, - cursor_row, - input_column - ); + trainlog_terminal_cursor_yx(tui_terminal, &cursor_row, &input_column); (void)cursor_row; - noecho(); - (void)curs_set(1); + + (void)trainlog_terminal_cursor_visible(tui_terminal, true); for (;;) { - wint_t value; - int rc; + int value; + char encoded[5]; - move( + trainlog_terminal_move(tui_terminal, row, input_column ); - clrtoeol(); + trainlog_terminal_clear_to_end(tui_terminal); if (used > 0U) { - addnstr( + trainlog_terminal_putn(tui_terminal, output, - (int)used + used ); } - refresh(); + trainlog_terminal_render(tui_terminal); - rc = get_wch(&value); - - if (rc == ERR) { + if (!trainlog_terminal_read_unicode(tui_terminal, &value, encoded)) { break; } - if (rc == KEY_CODE_YES) { - int key = - (int)value; + if (value < 0) { + int key = value; - if (key == KEY_ENTER) { + if (key == TRAINLOG_KEY_ENTER) { if (allow_empty || used > 0U) { accepted = true; @@ -224,8 +225,8 @@ static bool prompt_text( continue; } - if (key == KEY_BACKSPACE || - key == KEY_DC) { + if (key == TRAINLOG_KEY_BACKSPACE || + key == TRAINLOG_KEY_DELETE) { if (used > 0U) { do { --used; @@ -244,13 +245,12 @@ static bool prompt_text( continue; } - if (value == (wint_t)27) { + if (value == 27) { accepted = false; break; } - if (value == (wint_t)'\n' || - value == (wint_t)'\r') { + if (value == '\n' || value == '\r') { if (allow_empty || used > 0U) { accepted = true; @@ -260,8 +260,7 @@ static bool prompt_text( continue; } - if (value == (wint_t)8 || - value == (wint_t)127) { + if (value == 8 || value == 127) { if (used > 0U) { do { --used; @@ -277,27 +276,11 @@ static bool prompt_text( continue; } - if (value >= (wint_t)32) { - char encoded[MB_LEN_MAX]; - mbstate_t state; + if (value >= 32) { size_t encoded_size; + encoded_size = strlen(encoded); - (void)memset( - &state, - 0, - sizeof(state) - ); - - encoded_size = - wcrtomb( - encoded, - (wchar_t)value, - &state - ); - - if (encoded_size == - (size_t)-1 || - encoded_size == 0U || + if (encoded_size == 0U || encoded_size > output_size - used - 1U) { continue; @@ -314,8 +297,8 @@ static bool prompt_text( } } - noecho(); - (void)curs_set(0); + + (void)trainlog_terminal_cursor_visible(tui_terminal, false); return accepted; } @@ -393,7 +376,7 @@ static bool prompt_int_value( } status_line("Valeur entière invalide.", TRAINLOG_COLOR_ERROR); - refresh(); + trainlog_terminal_render(tui_terminal); } } @@ -423,7 +406,7 @@ static bool prompt_optional_double( } status_line("Nombre positif invalide.", TRAINLOG_COLOR_ERROR); - refresh(); + trainlog_terminal_render(tui_terminal); } } @@ -656,7 +639,7 @@ static void draw_exercise_performance_graph( double maximum = 0.0; const int left = 11; int width = - COLS - left - 4; + trainlog_terminal_columns(tui_terminal) - left - 4; if (points == NULL || count == 0U || @@ -677,7 +660,7 @@ static void draw_exercise_performance_graph( } if (selected_count == 0U) { - mvprintw( + trainlog_terminal_printf(tui_terminal, top + 1, 4, "Aucune performance réussie pour ce mode." @@ -715,13 +698,13 @@ static void draw_exercise_performance_graph( } if (mode == TRAINLOG_LOAD_ASSISTANCE) { - attron( - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_WARNING ) ); - mvprintw( + trainlog_terminal_printf(tui_terminal, top, left, measured_max @@ -729,14 +712,14 @@ static void draw_exercise_performance_graph( : "Assistance (kg) — moins = mieux" ); - attroff( - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_WARNING ) ); } else if (mode == TRAINLOG_LOAD_EXTERNAL) { - mvprintw( + trainlog_terminal_printf(tui_terminal, top, left, measured_max @@ -747,7 +730,7 @@ static void draw_exercise_performance_graph( tracking_mode == TRAINLOG_TRACKING_DURATION ) { - mvprintw( + trainlog_terminal_printf(tui_terminal, top, left, measured_max @@ -755,7 +738,7 @@ static void draw_exercise_performance_graph( : "Meilleure durée" ); } else { - mvprintw( + trainlog_terminal_printf(tui_terminal, top, left, measured_max @@ -764,22 +747,22 @@ static void draw_exercise_performance_graph( ); } - mvprintw( + trainlog_terminal_printf(tui_terminal, top + 1, 2, "%.1f", maximum ); - mvprintw( + trainlog_terminal_printf(tui_terminal, top + height - 1, 2, "%.1f", minimum ); - attron( - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_GRAPH ) ); @@ -837,21 +820,21 @@ static void draw_exercise_performance_graph( (line_x - previous_x)) / (x - previous_x)); - mvaddch( + trainlog_terminal_draw(tui_terminal, line_y, line_x, - (chtype)'.' + (uint32_t)'.' ); } } - mvaddch( + trainlog_terminal_draw(tui_terminal, y, x, chronological + 1U == selected_count - ? (chtype)'O' - : (chtype)'*' + ? (uint32_t)'O' + : (uint32_t)'*' ); previous_x = x; @@ -859,8 +842,8 @@ static void draw_exercise_performance_graph( } } - attroff( - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_GRAPH ) ); @@ -880,14 +863,14 @@ static void draw_exercise_performance_graph( newest ); - mvprintw( + trainlog_terminal_printf(tui_terminal, top + height, left, "%s", oldest ); - mvprintw( + trainlog_terminal_printf(tui_terminal, top + height, left + width - 10, "%s", @@ -906,7 +889,7 @@ static void exercise_panel( const char *label ) { - WINDOW *panel; + TrainlogPanel *panel; int height; int width; @@ -914,17 +897,15 @@ static void exercise_panel( left < 0 || bottom <= top || right <= left || - bottom >= LINES || - right >= COLS) { + bottom >= trainlog_terminal_rows(tui_terminal) || + right >= trainlog_terminal_columns(tui_terminal)) { return; } height = bottom - top + 1; width = right - left + 1; - panel = derwin( - stdscr, - height, + panel = tui_panel_create(tui_terminal, height, width, top, left @@ -934,20 +915,20 @@ static void exercise_panel( return; } - box(panel, 0, 0); + tui_panel_box(panel); if (label != NULL && label[0] != '\0' && width > 8) { - wattron( + tui_panel_style_on( panel, - A_BOLD | - trainlog_theme_attribute( + TRAINLOG_TEXT_BOLD | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); - mvwprintw( + tui_panel_print( panel, 0, 2, @@ -956,18 +937,17 @@ static void exercise_panel( label ); - wattroff( + tui_panel_style_off( panel, - A_BOLD | - trainlog_theme_attribute( + TRAINLOG_TEXT_BOLD | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); } - syncok(panel, TRUE); - wsyncup(panel); - delwin(panel); + tui_panel_commit(panel); + tui_panel_destroy(panel); } static void screen_exercise_performance( @@ -995,8 +975,8 @@ static void screen_exercise_performance( char best_text[128]; bool decorated = - COLS >= 100 && - LINES >= 36; + trainlog_terminal_columns(tui_terminal) >= 100 && + trainlog_terminal_rows(tui_terminal) >= 36; int summary_top = decorated ? 8 : 3; @@ -1014,7 +994,7 @@ static void screen_exercise_performance( decorated ? 26 : 22; int history_bottom = - LINES - 4; + trainlog_terminal_rows(tui_terminal) - 4; if (database == NULL || exercise == NULL) { @@ -1082,8 +1062,8 @@ static void screen_exercise_performance( ); if (decorated) { - erase(); - box(stdscr, 0, 0); + trainlog_terminal_erase(tui_terminal); + trainlog_terminal_box(tui_terminal, 0, 0, trainlog_terminal_rows(tui_terminal) - 1, trainlog_terminal_columns(tui_terminal) - 1); section_ascii_header( ":: P E R F O R M A N C E E X E R C I C E ::" @@ -1093,7 +1073,7 @@ static void screen_exercise_performance( summary_top, 2, summary_bottom, - COLS - 3, + trainlog_terminal_columns(tui_terminal) - 3, "PERFORMANCE" ); @@ -1101,7 +1081,7 @@ static void screen_exercise_performance( graph_top, 2, graph_bottom, - COLS - 3, + trainlog_terminal_columns(tui_terminal) - 3, "EVOLUTION" ); @@ -1111,28 +1091,28 @@ static void screen_exercise_performance( history_top, 2, history_bottom, - COLS - 3, + trainlog_terminal_columns(tui_terminal) - 3, "HISTORIQUE", true ); } - attron( - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); - mvprintw( - LINES - 2, + trainlog_terminal_printf(tui_terminal, + trainlog_terminal_rows(tui_terminal) - 2, 2, "%.*s", - COLS - 4, + trainlog_terminal_columns(tui_terminal) - 4, "b/Échap retour" ); - attroff( - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); @@ -1146,7 +1126,7 @@ static void screen_exercise_performance( summary_top, 2, summary_bottom, - COLS - 3, + trainlog_terminal_columns(tui_terminal) - 3, "PERFORMANCE" ); @@ -1154,7 +1134,7 @@ static void screen_exercise_performance( graph_top, 2, graph_bottom, - COLS - 3, + trainlog_terminal_columns(tui_terminal) - 3, "EVOLUTION" ); @@ -1164,34 +1144,34 @@ static void screen_exercise_performance( history_top, 2, history_bottom, - COLS - 3, + trainlog_terminal_columns(tui_terminal) - 3, "HISTORIQUE" ); } } - attron( - A_BOLD | - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + TRAINLOG_TEXT_BOLD | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); - mvprintw( + trainlog_terminal_printf(tui_terminal, summary_top + 1, 5, "%s", exercise->name ); - attroff( - A_BOLD | - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + TRAINLOG_TEXT_BOLD | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); - mvprintw( + trainlog_terminal_printf(tui_terminal, summary_top + 2, 5, "Séances enregistrées : %zu", @@ -1199,13 +1179,13 @@ static void screen_exercise_performance( ); if (latest == NULL) { - mvprintw( + trainlog_terminal_printf(tui_terminal, summary_top + 3, 5, "Aucune série réussie enregistrée." ); } else { - mvprintw( + trainlog_terminal_printf(tui_terminal, summary_top + 3, 5, "Mode suivi : %s", @@ -1214,19 +1194,19 @@ static void screen_exercise_performance( ) ); - mvprintw( + trainlog_terminal_printf(tui_terminal, summary_top + 4, 5, "Dernier meilleur set : %.*s", - COLS - 30, + trainlog_terminal_columns(tui_terminal) - 30, latest_text ); - mvprintw( + trainlog_terminal_printf(tui_terminal, summary_top + 5, 5, "Meilleur set enregistré : %.*s", - COLS - 33, + trainlog_terminal_columns(tui_terminal) - 33, best_text ); } @@ -1283,7 +1263,7 @@ static void screen_exercise_performance( sizeof(summary) ); - mvprintw( + trainlog_terminal_printf(tui_terminal, history_first_row + (int)index, 5, @@ -1292,22 +1272,22 @@ static void screen_exercise_performance( exercise_load_mode_label( points[index].load_mode ), - COLS - 40, + trainlog_terminal_columns(tui_terminal) - 40, summary ); } } - refresh(); + trainlog_terminal_render(tui_terminal); for (;;) { - int key = getch(); + int key = trainlog_terminal_get_key(tui_terminal); if (key == 'b' || key == 'B' || key == 27 || key == '\n' || - key == KEY_ENTER) { + key == TRAINLOG_KEY_ENTER) { return; } } @@ -1358,8 +1338,8 @@ static void screen_exercise_measured_max( size_t history_limit; bool decorated = - COLS >= 100 && - LINES >= 30; + trainlog_terminal_columns(tui_terminal) >= 100 && + trainlog_terminal_rows(tui_terminal) >= 30; int summary_top = decorated ? 8 : 3; @@ -1377,7 +1357,7 @@ static void screen_exercise_measured_max( decorated ? 24 : 18; int history_bottom = - LINES - 4; + trainlog_terminal_rows(tui_terminal) - 4; int key; @@ -1426,12 +1406,10 @@ static void screen_exercise_measured_max( } } - erase(); - box( - stdscr, - 0, - 0 - ); + trainlog_terminal_erase(tui_terminal); + trainlog_terminal_box(tui_terminal, 0, 0, + trainlog_terminal_rows(tui_terminal) - 1, + trainlog_terminal_columns(tui_terminal) - 1); if (decorated) { section_ascii_header( @@ -1442,7 +1420,7 @@ static void screen_exercise_measured_max( summary_top, 2, summary_bottom, - COLS - 3, + trainlog_terminal_columns(tui_terminal) - 3, "MAX MESURE" ); @@ -1450,7 +1428,7 @@ static void screen_exercise_measured_max( graph_top, 2, graph_bottom, - COLS - 3, + trainlog_terminal_columns(tui_terminal) - 3, "EVOLUTION DES TESTS MAX" ); @@ -1462,27 +1440,27 @@ static void screen_exercise_measured_max( history_top, 2, history_bottom, - COLS - 3, + trainlog_terminal_columns(tui_terminal) - 3, "HISTORIQUE TESTS MAX" ); } - attron( - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); - mvprintw( - LINES - 2, + trainlog_terminal_printf(tui_terminal, + trainlog_terminal_rows(tui_terminal) - 2, 2, "%.*s", - COLS - 4, + trainlog_terminal_columns(tui_terminal) - 4, "r arrondi charge b/Échap retour" ); - attroff( - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); @@ -1496,7 +1474,7 @@ static void screen_exercise_measured_max( summary_top, 2, summary_bottom, - COLS - 3, + trainlog_terminal_columns(tui_terminal) - 3, "MAX MESURE" ); @@ -1504,7 +1482,7 @@ static void screen_exercise_measured_max( graph_top, 2, graph_bottom, - COLS - 3, + trainlog_terminal_columns(tui_terminal) - 3, "EVOLUTION" ); @@ -1516,34 +1494,34 @@ static void screen_exercise_measured_max( history_top, 2, history_bottom, - COLS - 3, + trainlog_terminal_columns(tui_terminal) - 3, "HISTORIQUE" ); } } - attron( - A_BOLD | - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + TRAINLOG_TEXT_BOLD | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); - mvprintw( + trainlog_terminal_printf(tui_terminal, summary_top + 1, 5, "%s", exercise->name ); - attroff( - A_BOLD | - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + TRAINLOG_TEXT_BOLD | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); - mvprintw( + trainlog_terminal_printf(tui_terminal, summary_top + 2, 5, "Tests max : %zu · réussis : %zu", @@ -1552,13 +1530,13 @@ static void screen_exercise_measured_max( ); if (!summary.found) { - mvprintw( + trainlog_terminal_printf(tui_terminal, summary_top + 3, 5, "Aucun max mesuré réussi." ); - mvprintw( + trainlog_terminal_printf(tui_terminal, summary_top + 4, 5, "Seules les séances explicitement « Test de max » comptent." @@ -1591,21 +1569,21 @@ static void screen_exercise_measured_max( record_date ); - mvprintw( + trainlog_terminal_printf(tui_terminal, summary_top + 3, 5, "Actuel : %s · %.*s", current_date, - COLS - 32, + trainlog_terminal_columns(tui_terminal) - 32, current_text ); - mvprintw( + trainlog_terminal_printf(tui_terminal, summary_top + 4, 5, "Record même mode : %s · %.*s", record_date, - COLS - 42, + trainlog_terminal_columns(tui_terminal) - 42, record_text ); @@ -1635,7 +1613,7 @@ static void screen_exercise_measured_max( } if (valid) { - mvprintw( + trainlog_terminal_printf(tui_terminal, summary_top + 5, 5, "Travail : 60%% %.1f · 70%% %.1f · 80%% %.1f · 90%% %.1f kg", @@ -1645,7 +1623,7 @@ static void screen_exercise_measured_max( working[3] ); - mvprintw( + trainlog_terminal_printf(tui_terminal, summary_top + 6, 5, "Arrondi : %.1f kg (r pour changer) · aucun 1RM estimé", @@ -1656,37 +1634,37 @@ static void screen_exercise_measured_max( summary.current.load_mode == TRAINLOG_LOAD_ASSISTANCE ) { - attron( - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_WARNING ) ); - mvprintw( + trainlog_terminal_printf(tui_terminal, summary_top + 5, 5, "Assistance : moins de kg = mieux." ); - mvprintw( + trainlog_terminal_printf(tui_terminal, summary_top + 6, 5, "Pourcentages de charge non applicables à l'assistance." ); - attroff( - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_WARNING ) ); } else { - mvprintw( + trainlog_terminal_printf(tui_terminal, summary_top + 5, 5, "Sans charge externe : pourcentages non applicables." ); - mvprintw( + trainlog_terminal_printf(tui_terminal, summary_top + 6, 5, "Le max reste une valeur réellement réalisée, jamais estimée." @@ -1716,7 +1694,7 @@ static void screen_exercise_measured_max( true ); } else { - mvprintw( + trainlog_terminal_printf(tui_terminal, graph_top + 2, 5, "Aucun point de max mesuré à tracer." @@ -1759,7 +1737,7 @@ static void screen_exercise_measured_max( sizeof(text) ); - mvprintw( + trainlog_terminal_printf(tui_terminal, first_row + (int)index, 5, @@ -1769,21 +1747,21 @@ static void screen_exercise_measured_max( max_points[index] .load_mode ), - COLS - 40, + trainlog_terminal_columns(tui_terminal) - 40, text ); } } - refresh(); - key = getch(); + trainlog_terminal_render(tui_terminal); + key = trainlog_terminal_get_key(tui_terminal); if ( key == 'b' || key == 'B' || key == 27 || key == '\n' || - key == KEY_ENTER + key == TRAINLOG_KEY_ENTER ) { return; } @@ -1810,83 +1788,58 @@ static void section_ascii_header( const char *subtitle ) { - static const char *const logo[] = { - "TTTTT RRRR AAA IIIII N N L OOO GGG ", - " T R R A A I NN N L O O G ", - " T RRRR AAAAA I N N N L O O G GG", - " T R R A A I N NN L O O G G", - " T R R A A IIIII N N LLLLL OOO GGG " - }; + int header_left = 2; + int header_right = trainlog_terminal_columns(tui_terminal) - 3; + int subtitle_width; - const size_t line_count = - sizeof(logo) / - sizeof(logo[0]); - - size_t index; - - attron( - A_BOLD | - trainlog_theme_attribute( - TRAINLOG_COLOR_ACCENT - ) - ); - - for (index = 0U; - index < line_count; - ++index) { - int width = - (int)strlen(logo[index]); - - int column = - (COLS - width) / 2; - - if (column < 2) { - column = 2; - } - - mvprintw( - 1 + (int)index, - column, - "%.*s", - COLS - column - 2, - logo[index] - ); + if (header_right - header_left < 20) { + return; } - attroff( - A_BOLD | - trainlog_theme_attribute( + /* WHY: one restrained three-row plaque provides a recognizable terminal + * identity without consuming the panel rows reserved by every screen. */ + trainlog_terminal_style_on(tui_terminal, + trainlog_theme_style(TRAINLOG_COLOR_MUTED)); + trainlog_terminal_box(tui_terminal, 1, header_left, 3, header_right); + trainlog_terminal_style_off(tui_terminal, + trainlog_theme_style(TRAINLOG_COLOR_MUTED)); + + trainlog_terminal_style_on(tui_terminal, + TRAINLOG_TEXT_BOLD | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); - if (subtitle != NULL && - subtitle[0] != '\0') { - int width = - (int)strlen(subtitle); + trainlog_terminal_printf(tui_terminal, 1, header_left + 3, + "◆ TRAINLOG ◆"); - int column = - (COLS - width) / 2; + trainlog_terminal_style_off(tui_terminal, + TRAINLOG_TEXT_BOLD | + trainlog_theme_style( + TRAINLOG_COLOR_ACCENT + ) + ); - if (column < 2) { - column = 2; - } + if (subtitle != NULL && subtitle[0] != '\0') { + subtitle_width = header_right - header_left - 4; - attron( - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); - mvprintw( - 6, - column, - "%s", + trainlog_terminal_printf(tui_terminal, + 2, + header_left + 2, + "%.*s", + subtitle_width, subtitle ); - attroff( - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); @@ -1919,8 +1872,8 @@ static void section_scrollbar( return; } - attron( - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); @@ -1928,15 +1881,15 @@ static void section_scrollbar( for (row = top; row <= bottom; ++row) { - mvaddch( + trainlog_terminal_draw(tui_terminal, row, column, - ACS_VLINE + 0x2502U ); } - attroff( - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); @@ -1949,22 +1902,22 @@ static void section_scrollbar( (count - 1U) ); - attron( - A_BOLD | - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + TRAINLOG_TEXT_BOLD | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); - mvaddch( + trainlog_terminal_draw(tui_terminal, thumb, column, - ACS_CKBOARD + 0x2593U ); - attroff( - A_BOLD | - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + TRAINLOG_TEXT_BOLD | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); @@ -1985,12 +1938,12 @@ static void screen_exercises( size_t index; bool large_layout = - COLS >= 100 && - LINES >= 30; + trainlog_terminal_columns(tui_terminal) >= 100 && + trainlog_terminal_rows(tui_terminal) >= 30; bool framed = - COLS >= 90 && - LINES >= 24; + trainlog_terminal_columns(tui_terminal) >= 90 && + trainlog_terminal_rows(tui_terminal) >= 24; int list_top = large_layout @@ -1998,7 +1951,7 @@ static void screen_exercises( : 3; int list_bottom = - LINES - 4; + trainlog_terminal_rows(tui_terminal) - 4; int first_row = list_top + 1; @@ -2007,7 +1960,7 @@ static void screen_exercises( framed ? list_bottom - first_row - : LINES - 7; + : trainlog_terminal_rows(tui_terminal) - 7; int key; @@ -2040,8 +1993,8 @@ static void screen_exercises( } if (large_layout) { - erase(); - box(stdscr, 0, 0); + trainlog_terminal_erase(tui_terminal); + trainlog_terminal_box(tui_terminal, 0, 0, trainlog_terminal_rows(tui_terminal) - 1, trainlog_terminal_columns(tui_terminal) - 1); section_ascii_header( ":: E X E R C I C E S ::" @@ -2053,22 +2006,22 @@ static void screen_exercises( focus == 0 ); - attron( - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); - mvprintw( - LINES - 2, + trainlog_terminal_printf(tui_terminal, + trainlog_terminal_rows(tui_terminal) - 2, 2, "%.*s", - COLS - 4, + trainlog_terminal_columns(tui_terminal) - 4, "Tab zone ↑↓/PgUp/PgDn catalogue ←→ menu Entrée ouvrir m max mesuré a ajouter 0/Home accueil F1-F4 direct b/Échap retour" ); - attroff( - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); @@ -2085,7 +2038,7 @@ static void screen_exercises( list_top, 2, list_bottom, - COLS - 3, + trainlog_terminal_columns(tui_terminal) - 3, "CATALOGUE", focus == 1 ); @@ -2094,14 +2047,14 @@ static void screen_exercises( list_top, 2, list_bottom, - COLS - 3, + trainlog_terminal_columns(tui_terminal) - 3, "CATALOGUE" ); } } if (count == 0U) { - mvprintw( + trainlog_terminal_printf(tui_terminal, first_row + 1, framed ? 5 : 4, "Aucun exercice." @@ -2124,15 +2077,15 @@ static void screen_exercises( if (focus == 1 && absolute == selected) { - attron( - A_REVERSE | - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + TRAINLOG_TEXT_REVERSE | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); } - mvprintw( + trainlog_terminal_printf(tui_terminal, item_row, item_col, " %-42s [%s] ", @@ -2145,9 +2098,9 @@ static void screen_exercises( if (focus == 1 && absolute == selected) { - attroff( - A_REVERSE | - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + TRAINLOG_TEXT_REVERSE | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); @@ -2158,19 +2111,19 @@ static void screen_exercises( section_scrollbar( first_row, list_bottom - 1, - COLS - 5, + trainlog_terminal_columns(tui_terminal) - 5, selected, count, (size_t)visible_rows ); } - refresh(); - key = getch(); + trainlog_terminal_render(tui_terminal); + key = trainlog_terminal_get_key(tui_terminal); if (large_layout && - (key == '\t' || - key == KEY_BTAB)) { + (key == TRAINLOG_KEY_TAB || + key == TRAINLOG_KEY_SHIFT_TAB)) { focus = focus == 0 ? 1 @@ -2191,19 +2144,19 @@ primary_top_nav_forward(key)) { if (large_layout && focus == 0) { - if (key == KEY_LEFT) { + if (key == TRAINLOG_KEY_LEFT) { nav_selected = nav_selected > 0 ? nav_selected - 1 : 5; - } else if (key == KEY_RIGHT) { + } else if (key == TRAINLOG_KEY_RIGHT) { nav_selected = nav_selected < 5 ? nav_selected + 1 : 0; } else if ( key == '\n' || - key == KEY_ENTER + key == TRAINLOG_KEY_ENTER ) { if (primary_top_nav_activate( nav_selected @@ -2216,7 +2169,7 @@ if (primary_top_nav_activate( } if (count > 0U && - key == KEY_UP) { + key == TRAINLOG_KEY_UP) { selected = selected > 0U ? selected - 1U @@ -2226,7 +2179,7 @@ if (primary_top_nav_activate( } if (count > 0U && - key == KEY_DOWN) { + key == TRAINLOG_KEY_DOWN) { selected = selected + 1U < count ? selected + 1U @@ -2236,7 +2189,7 @@ if (primary_top_nav_activate( } if (count > 0U && - key == KEY_PPAGE) { + key == TRAINLOG_KEY_PAGE_UP) { size_t jump = (size_t)visible_rows; @@ -2249,7 +2202,7 @@ if (primary_top_nav_activate( } if (count > 0U && - key == KEY_NPAGE) { + key == TRAINLOG_KEY_PAGE_DOWN) { size_t jump = (size_t)visible_rows; @@ -2274,7 +2227,7 @@ if (primary_top_nav_activate( if (count > 0U && (key == '\n' || - key == KEY_ENTER)) { + key == TRAINLOG_KEY_ENTER)) { screen_exercise_performance( database, &exercises[selected] @@ -2542,12 +2495,12 @@ static bool choose_exercise( size_t top = 0U; size_t index; bool large_layout = - COLS >= 100 && - LINES >= 30; + trainlog_terminal_columns(tui_terminal) >= 100 && + trainlog_terminal_rows(tui_terminal) >= 30; int list_top = large_layout ? 8 : 3; int list_bottom = - LINES - 4; + trainlog_terminal_rows(tui_terminal) - 4; int first_row = list_top + 1; int visible_rows = @@ -2573,14 +2526,14 @@ static bool choose_exercise( "a ajouter un exercice · Échap annuler" ); - mvprintw( + trainlog_terminal_printf(tui_terminal, 4, 4, "Aucun exercice disponible." ); - refresh(); - key = getch(); + trainlog_terminal_render(tui_terminal); + key = trainlog_terminal_get_key(tui_terminal); if (key == 'a' || key == 'A') { @@ -2611,8 +2564,8 @@ static bool choose_exercise( } if (large_layout) { - erase(); - box(stdscr, 0, 0); + trainlog_terminal_erase(tui_terminal); + trainlog_terminal_box(tui_terminal, 0, 0, trainlog_terminal_rows(tui_terminal) - 1, trainlog_terminal_columns(tui_terminal) - 1); section_ascii_header( ":: C H O I S I R E X E R C I C E ::" @@ -2622,27 +2575,27 @@ static bool choose_exercise( list_top, 2, list_bottom, - COLS - 3, + trainlog_terminal_columns(tui_terminal) - 3, "CATALOGUE", true ); - attron( - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); - mvprintw( - LINES - 2, + trainlog_terminal_printf(tui_terminal, + trainlog_terminal_rows(tui_terminal) - 2, 2, "%.*s", - COLS - 4, + trainlog_terminal_columns(tui_terminal) - 4, "↑↓ choisir Entrée sélectionner a créer un exercice Échap annuler" ); - attroff( - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); @@ -2665,15 +2618,15 @@ static bool choose_exercise( (int)index; if (absolute == selected) { - attron( - A_REVERSE | - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + TRAINLOG_TEXT_REVERSE | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); } - mvprintw( + trainlog_terminal_printf(tui_terminal, item_row, large_layout ? 5 : 4, " %-42s [%s] ", @@ -2685,9 +2638,9 @@ static bool choose_exercise( ); if (absolute == selected) { - attroff( - A_REVERSE | - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + TRAINLOG_TEXT_REVERSE | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); @@ -2698,29 +2651,29 @@ static bool choose_exercise( section_scrollbar( first_row, list_bottom - 1, - COLS - 5, + trainlog_terminal_columns(tui_terminal) - 5, selected, count, (size_t)visible_rows ); } - refresh(); - key = getch(); + trainlog_terminal_render(tui_terminal); + key = trainlog_terminal_get_key(tui_terminal); - if (key == KEY_UP) { + if (key == TRAINLOG_KEY_UP) { selected = selected > 0U ? selected - 1U : count - 1U; - } else if (key == KEY_DOWN) { + } else if (key == TRAINLOG_KEY_DOWN) { selected = selected + 1U < count ? selected + 1U : 0U; } else if ( key == '\n' || - key == KEY_ENTER + key == TRAINLOG_KEY_ENTER ) { *output = exercises[selected]; @@ -2823,7 +2776,7 @@ static bool prompt_duration_value( "Durée invalide : 90, 90s, 1:30, 1m30, 2m.", TRAINLOG_COLOR_ERROR ); - refresh(); + trainlog_terminal_render(tui_terminal); } } @@ -2842,11 +2795,11 @@ static void draw_body_metric_graph( int width; if (count == 0U) { - mvprintw(row, 4, "Aucune donnée pour cette mesure."); + trainlog_terminal_printf(tui_terminal, row, 4, "Aucune donnée pour cette mesure."); return; } - width = COLS - 14; + width = trainlog_terminal_columns(tui_terminal) - 14; if (width < 10 || height < 3) { return; } @@ -2870,26 +2823,26 @@ static void draw_body_metric_graph( if (maximum == minimum) { int graph_row = row + (height / 2); - mvprintw(graph_row, 2, "%.1f %s", minimum, unit); + trainlog_terminal_printf(tui_terminal, graph_row, 2, "%.1f %s", minimum, unit); - attron(trainlog_theme_attribute(TRAINLOG_COLOR_GRAPH)); + trainlog_terminal_style_on(tui_terminal, trainlog_theme_style(TRAINLOG_COLOR_GRAPH)); for (index = start; index < count; ++index) { int x = 12 + (int)(index - start); - if (x < COLS - 2) { - mvaddch(graph_row, x, (chtype)'*'); + if (x < trainlog_terminal_columns(tui_terminal) - 2) { + trainlog_terminal_draw(tui_terminal, graph_row, x, (uint32_t)'*'); } } - attroff(trainlog_theme_attribute(TRAINLOG_COLOR_GRAPH)); + trainlog_terminal_style_off(tui_terminal, trainlog_theme_style(TRAINLOG_COLOR_GRAPH)); return; } - mvprintw(row, 2, "%.1f", maximum); - mvprintw(row + height - 1, 2, "%.1f", minimum); + trainlog_terminal_printf(tui_terminal, row, 2, "%.1f", maximum); + trainlog_terminal_printf(tui_terminal, row + height - 1, 2, "%.1f", minimum); - attron(trainlog_theme_attribute(TRAINLOG_COLOR_GRAPH)); + trainlog_terminal_style_on(tui_terminal, trainlog_theme_style(TRAINLOG_COLOR_GRAPH)); for (index = start; index < count; ++index) { double ratio = @@ -2908,12 +2861,12 @@ static void draw_body_metric_graph( y = row + height - 1; } - if (x < COLS - 2) { - mvaddch(y, x, (chtype)'*'); + if (x < trainlog_terminal_columns(tui_terminal) - 2) { + trainlog_terminal_draw(tui_terminal, y, x, (uint32_t)'*'); } } - attroff(trainlog_theme_attribute(TRAINLOG_COLOR_GRAPH)); + trainlog_terminal_style_off(tui_terminal, trainlog_theme_style(TRAINLOG_COLOR_GRAPH)); } static void add_body_observation(TrainlogDatabase *database) @@ -3187,32 +3140,31 @@ static void global_plot_point( TrainlogColorRole role ) { - chtype current; - chtype character; + uint32_t current; + uint32_t character; if (row < 0 || - row >= LINES || + row >= trainlog_terminal_rows(tui_terminal) || column < 0 || - column >= COLS) { + column >= trainlog_terminal_columns(tui_terminal)) { return; } - current = - mvinch(row, column) & - A_CHARTEXT; + /* Standard-plane writes are deterministic; a later series marks overlap. */ + current = (uint32_t)' '; character = - (chtype)(unsigned char)symbol; + (uint32_t)(unsigned char)symbol; - if (current != (chtype)' ' && - current != (chtype)'.' && + if (current != (uint32_t)' ' && + current != (uint32_t)'.' && current != character) { - character = (chtype)'#'; + character = (uint32_t)'#'; } - attron(trainlog_theme_attribute(role)); - mvaddch(row, column, character); - attroff(trainlog_theme_attribute(role)); + trainlog_terminal_style_on(tui_terminal, trainlog_theme_style(role)); + trainlog_terminal_draw(tui_terminal, row, column, character); + trainlog_terminal_style_off(tui_terminal, trainlog_theme_style(role)); } static void global_plot_segment( @@ -3320,7 +3272,7 @@ static void draw_global_body_overlay( const int graph_height = 5; const int graph_left = 8; int graph_width = - COLS - graph_left - 3; + trainlog_terminal_columns(tui_terminal) - graph_left - 3; (void)memset( series, @@ -3396,7 +3348,7 @@ static void draw_global_body_overlay( } if (date_count == 0U) { - mvprintw( + trainlog_terminal_printf(tui_terminal, 4, 4, "Aucune mensuration disponible pour la vue globale." @@ -3415,7 +3367,7 @@ static void draw_global_body_overlay( global_date_compare ); - mvprintw( + trainlog_terminal_printf(tui_terminal, 3, 4, "Vue globale — première mesure de chaque série = 100" @@ -3426,14 +3378,14 @@ static void draw_global_body_overlay( maximum = 101.0; } - mvprintw( + trainlog_terminal_printf(tui_terminal, graph_top, 2, "%.1f", maximum ); - mvprintw( + trainlog_terminal_printf(tui_terminal, graph_top + graph_height - 1, 2, "%.1f", @@ -3453,8 +3405,8 @@ static void draw_global_body_overlay( int column; - attron( - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); @@ -3463,15 +3415,15 @@ static void draw_global_body_overlay( column < graph_left + graph_width; ++column) { - mvaddch( + trainlog_terminal_draw(tui_terminal, baseline_row, column, - (chtype)'.' + (uint32_t)'.' ); } - attroff( - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); @@ -3583,15 +3535,15 @@ static void draw_global_body_overlay( column = row_index < 7U ? 4 - : (COLS / 2); + : (trainlog_terminal_columns(tui_terminal) / 2); - attron( - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + trainlog_theme_style( item->role ) ); - mvprintw( + trainlog_terminal_printf(tui_terminal, row, column, "%c %-16s %+.1f%%", @@ -3600,8 +3552,8 @@ static void draw_global_body_overlay( item->latest_percent ); - attroff( - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + trainlog_theme_style( item->role ) ); @@ -3759,8 +3711,8 @@ static void dashboard_draw_month_axis( graph_width / (int)(DASHBOARD_MONTH_COUNT - 1U); - attron( - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); @@ -3797,19 +3749,19 @@ static void dashboard_draw_month_axis( x = 2; } - if (x > COLS - 8) { - x = COLS - 8; + if (x > trainlog_terminal_columns(tui_terminal) - 8) { + x = trainlog_terminal_columns(tui_terminal) - 8; } - mvprintw( + trainlog_terminal_printf(tui_terminal, row, x, "%s", label ); } else { - if (x + 2 < COLS - 1) { - mvprintw( + if (x + 2 < trainlog_terminal_columns(tui_terminal) - 1) { + trainlog_terminal_printf(tui_terminal, row, x, "%02d", @@ -3819,8 +3771,8 @@ static void dashboard_draw_month_axis( } } - attroff( - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); @@ -3836,7 +3788,7 @@ static void dashboard_panel( const char *label ) { - WINDOW *panel; + TrainlogPanel *panel; int height; int width; @@ -3844,17 +3796,15 @@ static void dashboard_panel( left < 0 || bottom <= top || right <= left || - bottom >= LINES || - right >= COLS) { + bottom >= trainlog_terminal_rows(tui_terminal) || + right >= trainlog_terminal_columns(tui_terminal)) { return; } height = bottom - top + 1; width = right - left + 1; - panel = derwin( - stdscr, - height, + panel = tui_panel_create(tui_terminal, height, width, top, left @@ -3864,20 +3814,20 @@ static void dashboard_panel( return; } - box(panel, 0, 0); + tui_panel_box(panel); if (label != NULL && label[0] != '\0' && width > 8) { - wattron( + tui_panel_style_on( panel, - A_BOLD | - trainlog_theme_attribute( + TRAINLOG_TEXT_BOLD | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); - mvwprintw( + tui_panel_print( panel, 0, 2, @@ -3886,10 +3836,10 @@ static void dashboard_panel( label ); - wattroff( + tui_panel_style_off( panel, - A_BOLD | - trainlog_theme_attribute( + TRAINLOG_TEXT_BOLD | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); @@ -3897,12 +3847,11 @@ static void dashboard_panel( /* * derwin() shares the parent screen storage. syncok()+wsyncup() makes - * the panel border part of stdscr, so later dashboard content and one - * final refresh() compose cleanly. + * the panel border part of tui_terminal, so later dashboard content and one + * final trainlog_terminal_render(tui_terminal) compose cleanly. */ - syncok(panel, TRUE); - wsyncup(panel); - delwin(panel); + tui_panel_commit(panel); + tui_panel_destroy(panel); } /* TRAINLOG_PRIMARY_TOP_NAVIGATION */ @@ -3918,26 +3867,24 @@ static void focused_panel( bool active ) { - WINDOW *panel; + TrainlogPanel *panel; int height; int width; - chtype border_attribute; + uint32_t border_attribute; if (top < 0 || left < 0 || bottom <= top || right <= left || - bottom >= LINES || - right >= COLS) { + bottom >= trainlog_terminal_rows(tui_terminal) || + right >= trainlog_terminal_columns(tui_terminal)) { return; } height = bottom - top + 1; width = right - left + 1; - panel = derwin( - stdscr, - height, + panel = tui_panel_create(tui_terminal, height, width, top, left @@ -3949,25 +3896,25 @@ static void focused_panel( border_attribute = active - ? A_BOLD | - trainlog_theme_attribute( + ? TRAINLOG_TEXT_BOLD | + trainlog_theme_style( TRAINLOG_COLOR_WARNING ) - : trainlog_theme_attribute( + : trainlog_theme_style( TRAINLOG_COLOR_MUTED ); - wattron( + tui_panel_style_on( panel, border_attribute ); - box(panel, 0, 0); + tui_panel_box(panel); if (label != NULL && label[0] != '\0' && width > 8) { - mvwprintw( + tui_panel_print( panel, 0, 2, @@ -3977,14 +3924,13 @@ static void focused_panel( ); } - wattroff( + tui_panel_style_off( panel, border_attribute ); - syncok(panel, TRUE); - wsyncup(panel); - delwin(panel); + tui_panel_commit(panel); + tui_panel_destroy(panel); } static void primary_top_navbar( @@ -4009,7 +3955,7 @@ static void primary_top_navbar( 8, 2, 10, - COLS - 3, + trainlog_terminal_columns(tui_terminal) - 3, "NAVIGATION", focused ); @@ -4021,22 +3967,22 @@ static void primary_top_navbar( (int)strlen(labels[index]) + 4; if (index == selected_page) { - attron( - A_REVERSE | - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + TRAINLOG_TEXT_REVERSE | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); } else if (index == active_page) { - attron( - A_BOLD | - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + TRAINLOG_TEXT_BOLD | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); } - mvprintw( + trainlog_terminal_printf(tui_terminal, 9, column, " %s ", @@ -4044,16 +3990,16 @@ static void primary_top_navbar( ); if (index == selected_page) { - attroff( - A_REVERSE | - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + TRAINLOG_TEXT_REVERSE | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); } else if (index == active_page) { - attroff( - A_BOLD | - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + TRAINLOG_TEXT_BOLD | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); @@ -4076,9 +4022,7 @@ static bool primary_top_nav_activate( return true; } - return ungetch( - '0' + selected_page - ) != ERR; + return trainlog_terminal_push_key(tui_terminal, '0' + selected_page); } /* @@ -4094,30 +4038,30 @@ static bool primary_top_nav_forward( switch (key) { case '0': - case KEY_HOME: + case TRAINLOG_KEY_HOME: return true; - case KEY_F(1): + case TRAINLOG_KEY_F1: case '1': forwarded = '1'; break; - case KEY_F(2): + case TRAINLOG_KEY_F2: case '2': forwarded = '2'; break; - case KEY_F(3): + case TRAINLOG_KEY_F3: case '3': forwarded = '3'; break; - case KEY_F(4): + case TRAINLOG_KEY_F4: case '4': forwarded = '4'; break; - case KEY_F(5): + case TRAINLOG_KEY_F5: case '5': forwarded = '5'; break; @@ -4126,92 +4070,12 @@ static bool primary_top_nav_forward( return false; } - return ungetch(forwarded) != ERR; + return trainlog_terminal_push_key(tui_terminal, forwarded); } static void dashboard_ascii_header(void) { - static const char *const logo[] = { - "TTTTT RRRR AAA IIIII N N L OOO GGG ", - " T R R A A I NN N L O O G ", - " T RRRR AAAAA I N N N L O O G GG", - " T R R A A I N NN L O O G G", - " T R R A A IIIII N N LLLLL OOO GGG " - }; - - const size_t line_count = - sizeof(logo) / - sizeof(logo[0]); - - size_t index; - - attron( - A_BOLD | - trainlog_theme_attribute( - TRAINLOG_COLOR_ACCENT - ) - ); - - for (index = 0U; - index < line_count; - ++index) { - int width = - (int)strlen(logo[index]); - - int column = - (COLS - width) / 2; - - if (column < 2) { - column = 2; - } - - mvprintw( - 1 + (int)index, - column, - "%.*s", - COLS - column - 2, - logo[index] - ); - } - - attroff( - A_BOLD | - trainlog_theme_attribute( - TRAINLOG_COLOR_ACCENT - ) - ); - - attron( - trainlog_theme_attribute( - TRAINLOG_COLOR_MUTED - ) - ); - - { - const char *label = - ":: D A S H B O A R D ::"; - int width = - (int)strlen(label); - int column = - (COLS - width) / 2; - - if (column < 2) { - column = 2; - } - - mvprintw( - 6, - column, - "%s", - label - ); - } - - attroff( - trainlog_theme_attribute( - TRAINLOG_COLOR_MUTED - ) - ); + section_ascii_header("Accueil · Séance · Progression"); } static void draw_dashboard_body_graph( @@ -4231,8 +4095,8 @@ static void draw_dashboard_body_graph( sizeof(BODY_METRICS[0]); bool large_layout = - COLS >= 100 && - LINES >= 30; + trainlog_terminal_columns(tui_terminal) >= 100 && + trainlog_terminal_rows(tui_terminal) >= 30; int graph_panel_top = large_layout ? 11 : 2; @@ -4252,7 +4116,7 @@ static void draw_dashboard_body_graph( large_layout ? 10 : 8; int graph_right = - COLS - 5; + trainlog_terminal_columns(tui_terminal) - 5; int graph_width = graph_right - @@ -4290,7 +4154,7 @@ static void draw_dashboard_body_graph( graph_panel_top, 2, graph_panel_bottom, - COLS - 3, + trainlog_terminal_columns(tui_terminal) - 3, "EVOLUTION CORPORELLE - 12 MOIS" ); @@ -4298,26 +4162,26 @@ static void draw_dashboard_body_graph( legend_panel_top, 2, legend_panel_bottom, - COLS - 3, + trainlog_terminal_columns(tui_terminal) - 3, "MESURES" ); } else { - attron( - A_BOLD | - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + TRAINLOG_TEXT_BOLD | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); - mvprintw( + trainlog_terminal_printf(tui_terminal, 1, 3, "TRAINLOG :: DASHBOARD" ); - attroff( - A_BOLD | - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + TRAINLOG_TEXT_BOLD | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); @@ -4326,7 +4190,7 @@ static void draw_dashboard_body_graph( if (!dashboard_current_month_key( ¤t_key )) { - mvprintw( + trainlog_terminal_printf(tui_terminal, graph_top + 1, 5, "Impossible de déterminer le mois courant." @@ -4498,26 +4362,26 @@ static void draw_dashboard_body_graph( } if (plotted_series == 0U) { - attron( - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); - mvprintw( + trainlog_terminal_printf(tui_terminal, graph_top + 1, graph_left, "Premières courbes après 2 mois relevés pour une même mesure." ); - mvprintw( + trainlog_terminal_printf(tui_terminal, graph_top + 2, graph_left, "Un mois sans relevé reste vide." ); - attroff( - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); @@ -4534,14 +4398,14 @@ static void draw_dashboard_body_graph( maximum = 1.0; } - mvprintw( + trainlog_terminal_printf(tui_terminal, graph_top, 3, "%+.1f%%", maximum ); - mvprintw( + trainlog_terminal_printf(tui_terminal, graph_top + graph_height - 1, 3, "%+.1f%%", @@ -4560,8 +4424,8 @@ static void draw_dashboard_body_graph( int column; - attron( - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); @@ -4569,15 +4433,15 @@ static void draw_dashboard_body_graph( for (column = graph_left; column <= graph_right; ++column) { - mvaddch( + trainlog_terminal_draw(tui_terminal, zero_row, column, - (chtype)'.' + (uint32_t)'.' ); } - attroff( - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); @@ -4681,7 +4545,7 @@ static void draw_dashboard_body_graph( large_layout ? 3 : 2; int cell_width = - (COLS - 8) / + (trainlog_terminal_columns(tui_terminal) - 8) / columns; for (metric_index = 0U; @@ -4779,13 +4643,13 @@ static void draw_dashboard_body_graph( ); } - attron( - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + trainlog_theme_style( item->role ) ); - mvprintw( + trainlog_terminal_printf(tui_terminal, row, column, "%c %-*.*s %6.1f", @@ -4796,15 +4660,15 @@ static void draw_dashboard_body_graph( latest ); - mvprintw( + trainlog_terminal_printf(tui_terminal, row, column + cell_width - 7, "%6s", evolution ); - attroff( - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + trainlog_theme_style( item->role ) ); @@ -4834,21 +4698,21 @@ static DashboardAction screen_dashboard( for (;;) { bool large_layout = - COLS >= 100 && - LINES >= 30; + trainlog_terminal_columns(tui_terminal) >= 100 && + trainlog_terminal_rows(tui_terminal) >= 30; int nav_top = - large_layout ? 8 : LINES - 4; + large_layout ? 8 : trainlog_terminal_rows(tui_terminal) - 4; int nav_bottom = - large_layout ? 10 : LINES - 3; + large_layout ? 10 : trainlog_terminal_rows(tui_terminal) - 3; int key; int index; int column; - erase(); - box(stdscr, 0, 0); + trainlog_terminal_erase(tui_terminal); + trainlog_terminal_box(tui_terminal, 0, 0, trainlog_terminal_rows(tui_terminal) - 1, trainlog_terminal_columns(tui_terminal) - 1); if (large_layout) { dashboard_ascii_header(); @@ -4857,7 +4721,7 @@ static DashboardAction screen_dashboard( nav_top, 2, nav_bottom, - COLS - 3, + trainlog_terminal_columns(tui_terminal) - 3, "NAVIGATION", true ); @@ -4875,27 +4739,27 @@ static DashboardAction screen_dashboard( (int)strlen(labels[index]) + 4; if (index == selected) { - attron( - A_REVERSE | - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + TRAINLOG_TEXT_REVERSE | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); } - mvprintw( + trainlog_terminal_printf(tui_terminal, large_layout ? nav_top + 1 - : LINES - 3, + : trainlog_terminal_rows(tui_terminal) - 3, column, " %s ", labels[index] ); if (index == selected) { - attroff( - A_REVERSE | - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + TRAINLOG_TEXT_REVERSE | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); @@ -4904,40 +4768,40 @@ static DashboardAction screen_dashboard( column += width + 2; } - attron( - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); - mvprintw( - LINES - 2, + trainlog_terminal_printf(tui_terminal, + trainlog_terminal_rows(tui_terminal) - 2, 2, "%.*s", - COLS - 4, + trainlog_terminal_columns(tui_terminal) - 4, footer ); - attroff( - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); - refresh(); - key = getch(); + trainlog_terminal_render(tui_terminal); + key = trainlog_terminal_get_key(tui_terminal); switch (key) { - case KEY_UP: - case KEY_LEFT: + case TRAINLOG_KEY_UP: + case TRAINLOG_KEY_LEFT: selected = selected > 0 ? selected - 1 : 5; break; - case KEY_DOWN: - case KEY_RIGHT: + case TRAINLOG_KEY_DOWN: + case TRAINLOG_KEY_RIGHT: selected = selected < 5 ? selected + 1 @@ -4945,7 +4809,7 @@ static DashboardAction screen_dashboard( break; case '\n': - case KEY_ENTER: + case TRAINLOG_KEY_ENTER: switch (selected) { case 0: break; @@ -4965,27 +4829,27 @@ static DashboardAction screen_dashboard( break; case '0': - case KEY_HOME: + case TRAINLOG_KEY_HOME: selected = 0; break; - case KEY_F(1): + case TRAINLOG_KEY_F1: case '1': return DASHBOARD_NEW_SESSION; - case KEY_F(2): + case TRAINLOG_KEY_F2: case '2': return DASHBOARD_HISTORY; - case KEY_F(3): + case TRAINLOG_KEY_F3: case '3': return DASHBOARD_EXERCISES; - case KEY_F(4): + case TRAINLOG_KEY_F4: case '4': return DASHBOARD_BODY; - case KEY_F(5): + case TRAINLOG_KEY_F5: case '5': return DASHBOARD_SYNC; @@ -5242,7 +5106,7 @@ static bool build_session_exercise( TRAINLOG_COLOR_ERROR ); - refresh(); + trainlog_terminal_render(tui_terminal); } } else { if (!prompt_int_value( @@ -5316,7 +5180,7 @@ static bool build_session_exercise( "Échap annuler · Durées : 90, 90s, 1:30, 1m30, 2m" ); - mvprintw( + trainlog_terminal_printf(tui_terminal, 3, 4, "Série %zu / %zu", @@ -5443,14 +5307,14 @@ static bool choose_session_type( for (;;) { bool large_layout = - COLS >= 100 && - LINES >= 30; + trainlog_terminal_columns(tui_terminal) >= 100 && + trainlog_terminal_rows(tui_terminal) >= 30; int key; if (large_layout) { - erase(); - box(stdscr, 0, 0); + trainlog_terminal_erase(tui_terminal); + trainlog_terminal_box(tui_terminal, 0, 0, trainlog_terminal_rows(tui_terminal) - 1, trainlog_terminal_columns(tui_terminal) - 1); section_ascii_header( ":: N O U V E L L E S E A N C E ::" @@ -5466,42 +5330,42 @@ static bool choose_session_type( 11, 4, 20, - COLS - 5, + trainlog_terminal_columns(tui_terminal) - 5, "TYPE DE SEANCE", focus == 1 ); - attron( - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); - mvprintw( - LINES - 2, + trainlog_terminal_printf(tui_terminal, + trainlog_terminal_rows(tui_terminal) - 2, 2, "%.*s", - COLS - 4, + trainlog_terminal_columns(tui_terminal) - 4, "Tab zone ←→ menu ↑↓ type Entrée valider 0/Home accueil F2-F4 direct Échap annuler" ); - attroff( - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); if (focus == 1 && selected == 0) { - attron( - A_REVERSE | - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + TRAINLOG_TEXT_REVERSE | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); } - mvprintw( + trainlog_terminal_printf(tui_terminal, 13, 7, " Entraînement " @@ -5509,43 +5373,43 @@ static bool choose_session_type( if (focus == 1 && selected == 0) { - attroff( - A_REVERSE | - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + TRAINLOG_TEXT_REVERSE | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); } - attron( - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); - mvprintw( + trainlog_terminal_printf(tui_terminal, 14, 9, "Séance normale : progression, volume, travail courant." ); - attroff( - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); if (focus == 1 && selected == 1) { - attron( - A_REVERSE | - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + TRAINLOG_TEXT_REVERSE | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); } - mvprintw( + trainlog_terminal_printf(tui_terminal, 17, 7, " Test de max " @@ -5553,35 +5417,35 @@ static bool choose_session_type( if (focus == 1 && selected == 1) { - attroff( - A_REVERSE | - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + TRAINLOG_TEXT_REVERSE | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); } - attron( - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); - mvprintw( + trainlog_terminal_printf(tui_terminal, 18, 9, "Séance explicitement dédiée aux mesures de max." ); - attroff( - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); } else { - WINDOW *panel; + TrainlogPanel *panel; int panel_width = - COLS - 8; + trainlog_terminal_columns(tui_terminal) - 8; draw_shell( "TRAINLOG — Nouvelle séance", @@ -5592,9 +5456,7 @@ static bool choose_session_type( panel_width = 40; } - panel = derwin( - stdscr, - 9, + panel = tui_panel_create(tui_terminal, 9, panel_width, 3, 4 @@ -5604,42 +5466,42 @@ static bool choose_session_type( return false; } - box(panel, 0, 0); + tui_panel_box(panel); - wattron( + tui_panel_style_on( panel, - A_BOLD | - trainlog_theme_attribute( + TRAINLOG_TEXT_BOLD | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); - mvwprintw( + tui_panel_print( panel, 0, 2, " TYPE DE SEANCE " ); - wattroff( + tui_panel_style_off( panel, - A_BOLD | - trainlog_theme_attribute( + TRAINLOG_TEXT_BOLD | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); if (selected == 0) { - wattron( + tui_panel_style_on( panel, - A_REVERSE | - trainlog_theme_attribute( + TRAINLOG_TEXT_REVERSE | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); } - mvwprintw( + tui_panel_print( panel, 2, 3, @@ -5647,16 +5509,16 @@ static bool choose_session_type( ); if (selected == 0) { - wattroff( + tui_panel_style_off( panel, - A_REVERSE | - trainlog_theme_attribute( + TRAINLOG_TEXT_REVERSE | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); } - mvwprintw( + tui_panel_print( panel, 3, 5, @@ -5664,16 +5526,16 @@ static bool choose_session_type( ); if (selected == 1) { - wattron( + tui_panel_style_on( panel, - A_REVERSE | - trainlog_theme_attribute( + TRAINLOG_TEXT_REVERSE | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); } - mvwprintw( + tui_panel_print( panel, 5, 3, @@ -5681,33 +5543,32 @@ static bool choose_session_type( ); if (selected == 1) { - wattroff( + tui_panel_style_off( panel, - A_REVERSE | - trainlog_theme_attribute( + TRAINLOG_TEXT_REVERSE | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); } - mvwprintw( + tui_panel_print( panel, 6, 5, "Séance explicitement dédiée aux mesures de max." ); - syncok(panel, TRUE); - wsyncup(panel); - delwin(panel); + tui_panel_commit(panel); + tui_panel_destroy(panel); } - refresh(); - key = getch(); + trainlog_terminal_render(tui_terminal); + key = trainlog_terminal_get_key(tui_terminal); if (large_layout && - (key == '\t' || - key == KEY_BTAB)) { + (key == TRAINLOG_KEY_TAB || + key == TRAINLOG_KEY_SHIFT_TAB)) { focus = focus == 0 ? 1 @@ -5721,19 +5582,19 @@ static bool choose_session_type( if (large_layout && (key == '0' || - key == KEY_HOME)) { + key == TRAINLOG_KEY_HOME)) { return false; } if (large_layout && (key == '2' || - key == KEY_F(2) || + key == TRAINLOG_KEY_F2 || key == '3' || - key == KEY_F(3) || + key == TRAINLOG_KEY_F3 || key == '4' || - key == KEY_F(4) || + key == TRAINLOG_KEY_F4 || key == '5' || - key == KEY_F(5))) { + key == TRAINLOG_KEY_F5)) { if (primary_top_nav_forward(key)) { return false; } @@ -5743,19 +5604,19 @@ static bool choose_session_type( if (large_layout && focus == 0) { - if (key == KEY_LEFT) { + if (key == TRAINLOG_KEY_LEFT) { nav_selected = nav_selected > 0 ? nav_selected - 1 : 5; - } else if (key == KEY_RIGHT) { + } else if (key == TRAINLOG_KEY_RIGHT) { nav_selected = nav_selected < 5 ? nav_selected + 1 : 0; } else if ( key == '\n' || - key == KEY_ENTER + key == TRAINLOG_KEY_ENTER ) { if (nav_selected == 1) { focus = 1; @@ -5771,10 +5632,10 @@ static bool choose_session_type( continue; } - if (key == KEY_UP || - key == KEY_LEFT || - key == KEY_DOWN || - key == KEY_RIGHT) { + if (key == TRAINLOG_KEY_UP || + key == TRAINLOG_KEY_LEFT || + key == TRAINLOG_KEY_DOWN || + key == TRAINLOG_KEY_RIGHT) { selected = selected == 0 ? 1 @@ -5783,7 +5644,7 @@ static bool choose_session_type( } if (key == '\n' || - key == KEY_ENTER) { + key == TRAINLOG_KEY_ENTER) { *output = selected == 0 ? TRAINLOG_SESSION_TRAINING @@ -6049,7 +5910,7 @@ static bool confirm_draft_delete( "1 confirmer 0 annuler" ); - mvprintw( + trainlog_terminal_printf(tui_terminal, 4, 4, "%s", @@ -6092,14 +5953,14 @@ static bool edit_session_draft( size_t top = 0U; bool large_layout = - COLS >= 100 && - LINES >= 30; + trainlog_terminal_columns(tui_terminal) >= 100 && + trainlog_terminal_rows(tui_terminal) >= 30; int frame_top = large_layout ? 8 : 3; int frame_bottom = - LINES - 4; + trainlog_terminal_rows(tui_terminal) - 4; int first_row = frame_top + 3; @@ -6130,8 +5991,8 @@ static bool edit_session_draft( } if (large_layout) { - erase(); - box(stdscr, 0, 0); + trainlog_terminal_erase(tui_terminal); + trainlog_terminal_box(tui_terminal, 0, 0, trainlog_terminal_rows(tui_terminal) - 1, trainlog_terminal_columns(tui_terminal) - 1); section_ascii_header( ":: S E A N C E E N C O U R S ::" @@ -6141,27 +6002,27 @@ static bool edit_session_draft( frame_top, 2, frame_bottom, - COLS - 3, + trainlog_terminal_columns(tui_terminal) - 3, "RESUME AVANT ENREGISTREMENT", true ); - attron( - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); - mvprintw( - LINES - 2, + trainlog_terminal_printf(tui_terminal, + trainlog_terminal_rows(tui_terminal) - 2, 2, "%.*s", - COLS - 4, + trainlog_terminal_columns(tui_terminal) - 4, "↑↓ choisir e/Entrée modifier a ajouter d supprimer f enregistrer q/Échap abandonner" ); - attroff( - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); @@ -6172,14 +6033,14 @@ static bool edit_session_draft( ); } - attron( - A_BOLD | - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + TRAINLOG_TEXT_BOLD | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); - mvprintw( + trainlog_terminal_printf(tui_terminal, frame_top + 1, large_layout ? 5 : 4, "Type : %s", @@ -6188,15 +6049,15 @@ static bool edit_session_draft( ) ); - attroff( - A_BOLD | - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + TRAINLOG_TEXT_BOLD | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); if (*count == 0U) { - mvprintw( + trainlog_terminal_printf(tui_terminal, first_row + 1, large_layout ? 5 : 4, "Aucun exercice saisi. a = ajouter." @@ -6223,15 +6084,15 @@ static bool edit_session_draft( ); if (absolute == selected) { - attron( - A_REVERSE | - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + TRAINLOG_TEXT_REVERSE | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); } - mvprintw( + trainlog_terminal_printf(tui_terminal, row, large_layout ? 5 : 4, " %2zu %-36.36s %-28.28s ", @@ -6241,17 +6102,17 @@ static bool edit_session_draft( ); if (absolute == selected) { - attroff( - A_REVERSE | - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + TRAINLOG_TEXT_REVERSE | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); } } - refresh(); - key = getch(); + trainlog_terminal_render(tui_terminal); + key = trainlog_terminal_get_key(tui_terminal); if (key == 'q' || key == 'Q' || @@ -6267,15 +6128,15 @@ static bool edit_session_draft( TRAINLOG_COLOR_WARNING ); - refresh(); - (void)getch(); + trainlog_terminal_render(tui_terminal); + (void)trainlog_terminal_get_key(tui_terminal); continue; } return true; } - if (key == KEY_UP && + if (key == TRAINLOG_KEY_UP && *count > 0U) { selected = selected > 0U @@ -6284,7 +6145,7 @@ static bool edit_session_draft( continue; } - if (key == KEY_DOWN && + if (key == TRAINLOG_KEY_DOWN && *count > 0U) { selected = selected + 1U < *count @@ -6302,8 +6163,8 @@ static bool edit_session_draft( TRAINLOG_COLOR_WARNING ); - refresh(); - (void)getch(); + trainlog_terminal_render(tui_terminal); + (void)trainlog_terminal_get_key(tui_terminal); continue; } @@ -6322,7 +6183,7 @@ static bool edit_session_draft( if ((key == 'e' || key == 'E' || key == '\n' || - key == KEY_ENTER) && + key == TRAINLOG_KEY_ENTER) && *count > 0U) { TrainlogSessionDraftExercise replacement; @@ -6756,39 +6617,39 @@ static void screen_new_session( ); if (status == TRAINLOG_STATUS_OK) { - attron( - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_SUCCESS ) ); - mvprintw( + trainlog_terminal_printf(tui_terminal, 4, 2, "✓ Séance enregistrée." ); - attroff( - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_SUCCESS ) ); - mvprintw( + trainlog_terminal_printf(tui_terminal, 6, 2, "Début : %s", started_at ); - mvprintw( + trainlog_terminal_printf(tui_terminal, 7, 2, "Fin : %s", ended_at ); - mvprintw( + trainlog_terminal_printf(tui_terminal, 8, 2, "Type : %s", @@ -6797,7 +6658,7 @@ static void screen_new_session( ) ); - mvprintw( + trainlog_terminal_printf(tui_terminal, 9, 2, "Exercices : %zu", @@ -6868,14 +6729,14 @@ static void screen_session_detail( for (;;) { bool decorated = - COLS >= 100 && - LINES >= 32; + trainlog_terminal_columns(tui_terminal) >= 100 && + trainlog_terminal_rows(tui_terminal) >= 32; int key; if (decorated) { - erase(); - box(stdscr, 0, 0); + trainlog_terminal_erase(tui_terminal); + trainlog_terminal_box(tui_terminal, 0, 0, trainlog_terminal_rows(tui_terminal) - 1, trainlog_terminal_columns(tui_terminal) - 1); section_ascii_header( ":: D E T A I L S E A N C E ::" @@ -6885,47 +6746,47 @@ static void screen_session_detail( 8, 2, 13, - COLS - 3, + trainlog_terminal_columns(tui_terminal) - 3, "SEANCE" ); focused_panel( 14, 2, - LINES - 4, - COLS - 3, + trainlog_terminal_rows(tui_terminal) - 4, + trainlog_terminal_columns(tui_terminal) - 3, "EXERCICE", true ); - attron( - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); - mvprintw( - LINES - 2, + trainlog_terminal_printf(tui_terminal, + trainlog_terminal_rows(tui_terminal) - 2, 2, "%.*s", - COLS - 4, + trainlog_terminal_columns(tui_terminal) - 4, "←→/↑↓ exercice précédent/suivant e modifier la séance b/Échap retour" ); - attroff( - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); - mvprintw( + trainlog_terminal_printf(tui_terminal, 10, 5, "Début : %s", session.started_at ); - mvprintw( + trainlog_terminal_printf(tui_terminal, 11, 5, "Fin : %s", @@ -6934,7 +6795,7 @@ static void screen_session_detail( : "séance ouverte" ); - mvprintw( + trainlog_terminal_printf(tui_terminal, 12, 5, "Type : %s", @@ -6948,14 +6809,14 @@ static void screen_session_detail( "←→/↑↓ naviguer e modifier b/Échap retour" ); - mvprintw( + trainlog_terminal_printf(tui_terminal, 3, 4, "Début : %s", session.started_at ); - mvprintw( + trainlog_terminal_printf(tui_terminal, 4, 4, "Fin : %s", @@ -6964,7 +6825,7 @@ static void screen_session_detail( : "séance ouverte" ); - mvprintw( + trainlog_terminal_printf(tui_terminal, 5, 4, "Type : %s", @@ -6975,7 +6836,7 @@ static void screen_session_detail( } if (count == 0U) { - mvprintw( + trainlog_terminal_printf(tui_terminal, decorated ? 17 : 7, decorated ? 5 : 4, "Aucun exercice dans cette séance." @@ -7013,14 +6874,14 @@ static void screen_session_detail( ); } - attron( - A_BOLD | - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + TRAINLOG_TEXT_BOLD | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); - mvprintw( + trainlog_terminal_printf(tui_terminal, title_row, decorated ? 5 : 4, "Exercice %zu/%zu — %s", @@ -7029,20 +6890,20 @@ static void screen_session_detail( exercise->name ); - attroff( - A_BOLD | - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + TRAINLOG_TEXT_BOLD | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); - mvprintw( + trainlog_terminal_printf(tui_terminal, mode_row, decorated ? 5 : 4, "Mode : continu" ); - mvprintw( + trainlog_terminal_printf(tui_terminal, row++, decorated ? 5 : 4, "Durée : %s", @@ -7050,7 +6911,7 @@ static void screen_session_detail( ); if (exercise->has_continuous_speed != 0) { - mvprintw( + trainlog_terminal_printf(tui_terminal, row++, decorated ? 5 : 4, "Vitesse : %.1f km/h", @@ -7059,7 +6920,7 @@ static void screen_session_detail( } if (exercise->has_continuous_distance != 0) { - mvprintw( + trainlog_terminal_printf(tui_terminal, row++, decorated ? 5 : 4, "Distance : %.2f km", @@ -7067,22 +6928,22 @@ static void screen_session_detail( ); } - attron( - A_BOLD | - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + TRAINLOG_TEXT_BOLD | + trainlog_theme_style( TRAINLOG_COLOR_SUCCESS ) ); - mvprintw( + trainlog_terminal_printf(tui_terminal, row + 1, decorated ? 5 : 4, "Réalisé : activité continue" ); - attroff( - A_BOLD | - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + TRAINLOG_TEXT_BOLD | + trainlog_theme_style( TRAINLOG_COLOR_SUCCESS ) ); @@ -7135,14 +6996,14 @@ static void screen_session_detail( ); } - attron( - A_BOLD | - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + TRAINLOG_TEXT_BOLD | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); - mvprintw( + trainlog_terminal_printf(tui_terminal, title_row, decorated ? 5 : 4, "Exercice %zu/%zu — %s", @@ -7151,14 +7012,14 @@ static void screen_session_detail( exercise->name ); - attroff( - A_BOLD | - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + TRAINLOG_TEXT_BOLD | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); - mvprintw( + trainlog_terminal_printf(tui_terminal, mode_row, decorated ? 5 : 4, "Mode : %-12s Charge : %-10s Repos : %s", @@ -7175,7 +7036,7 @@ static void screen_session_detail( if ( exercise->target_sets <= 0 ) { - mvprintw( + trainlog_terminal_printf(tui_terminal, target_row, decorated ? 5 : 4, "Cible : non renseignée" @@ -7184,7 +7045,7 @@ static void screen_session_detail( exercise->tracking_mode == TRAINLOG_TRACKING_REPS ) { - mvprintw( + trainlog_terminal_printf(tui_terminal, target_row, decorated ? 5 : 4, "Cible : %d série(s) × %d reps", @@ -7192,7 +7053,7 @@ static void screen_session_detail( exercise->target_reps ); } else { - mvprintw( + trainlog_terminal_printf(tui_terminal, target_row, decorated ? 5 : 4, "Cible : %d série(s) × %s", @@ -7202,65 +7063,65 @@ static void screen_session_detail( } if (exercise->has_target_weight != 0) { - mvprintw( + trainlog_terminal_printf(tui_terminal, weight_row, decorated ? 5 : 4, "Charge cible : %.1f kg", exercise->target_weight_kg ); } else { - mvprintw( + trainlog_terminal_printf(tui_terminal, weight_row, decorated ? 5 : 4, "Charge cible : —" ); } - attron( - A_BOLD | - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + TRAINLOG_TEXT_BOLD | + trainlog_theme_style( TRAINLOG_COLOR_SUCCESS ) ); - mvprintw( + trainlog_terminal_printf(tui_terminal, actual_row, decorated ? 5 : 4, "Réalisé : %zu série(s)", exercise->actual_set_count ); - attroff( - A_BOLD | - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + TRAINLOG_TEXT_BOLD | + trainlog_theme_style( TRAINLOG_COLOR_SUCCESS ) ); - mvprintw( + trainlog_terminal_printf(tui_terminal, summary_row, decorated ? 5 : 4, "%.*s", - COLS - 10, + trainlog_terminal_columns(tui_terminal) - 10, exercise->actual_summary ); if (exercise->load_mode == TRAINLOG_LOAD_ASSISTANCE) { - attron( - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_WARNING ) ); - mvprintw( + trainlog_terminal_printf(tui_terminal, warning_row, decorated ? 5 : 4, "Assistance : plus de kg = davantage d'aide." ); - attroff( - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_WARNING ) ); @@ -7268,8 +7129,8 @@ static void screen_session_detail( } } - refresh(); - key = getch(); + trainlog_terminal_render(tui_terminal); + key = trainlog_terminal_get_key(tui_terminal); if (key == 'b' || key == 'B' || @@ -7288,16 +7149,16 @@ static void screen_session_detail( } if (count > 0U && - (key == KEY_RIGHT || - key == KEY_DOWN)) { + (key == TRAINLOG_KEY_RIGHT || + key == TRAINLOG_KEY_DOWN)) { selected = selected + 1U < count ? selected + 1U : 0U; } else if ( count > 0U && - (key == KEY_LEFT || - key == KEY_UP) + (key == TRAINLOG_KEY_LEFT || + key == TRAINLOG_KEY_UP) ) { selected = selected > 0U @@ -7325,9 +7186,9 @@ static void history_ascii_header(void) size_t index; - attron( - A_BOLD | - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + TRAINLOG_TEXT_BOLD | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); @@ -7339,30 +7200,30 @@ static void history_ascii_header(void) (int)strlen(logo[index]); int column = - (COLS - width) / 2; + (trainlog_terminal_columns(tui_terminal) - width) / 2; if (column < 2) { column = 2; } - mvprintw( + trainlog_terminal_printf(tui_terminal, 1 + (int)index, column, "%.*s", - COLS - column - 2, + trainlog_terminal_columns(tui_terminal) - column - 2, logo[index] ); } - attroff( - A_BOLD | - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + TRAINLOG_TEXT_BOLD | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); - attron( - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); @@ -7375,13 +7236,13 @@ static void history_ascii_header(void) (int)strlen(label); int column = - (COLS - width) / 2; + (trainlog_terminal_columns(tui_terminal) - width) / 2; if (column < 2) { column = 2; } - mvprintw( + trainlog_terminal_printf(tui_terminal, 6, column, "%s", @@ -7389,8 +7250,8 @@ static void history_ascii_header(void) ); } - attroff( - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); @@ -7422,8 +7283,8 @@ static void history_scrollbar( return; } - attron( - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); @@ -7431,15 +7292,15 @@ static void history_scrollbar( for (row = top; row <= bottom; ++row) { - mvaddch( + trainlog_terminal_draw(tui_terminal, row, column, - ACS_VLINE + 0x2502U ); } - attroff( - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); @@ -7452,22 +7313,22 @@ static void history_scrollbar( (count - 1U) ); - attron( - A_BOLD | - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + TRAINLOG_TEXT_BOLD | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); - mvaddch( + trainlog_terminal_draw(tui_terminal, thumb, column, - ACS_CKBOARD + 0x2593U ); - attroff( - A_BOLD | - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + TRAINLOG_TEXT_BOLD | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); @@ -7490,8 +7351,8 @@ static void screen_history( size_t index; bool large_layout = - COLS >= 100 && - LINES >= 30; + trainlog_terminal_columns(tui_terminal) >= 100 && + trainlog_terminal_rows(tui_terminal) >= 30; int list_top = large_layout @@ -7499,7 +7360,7 @@ static void screen_history( : 3; int list_bottom = - LINES - 4; + trainlog_terminal_rows(tui_terminal) - 4; int first_row = list_top + 1; @@ -7523,8 +7384,8 @@ static void screen_history( return; } - erase(); - box(stdscr, 0, 0); + trainlog_terminal_erase(tui_terminal); + trainlog_terminal_box(tui_terminal, 0, 0, trainlog_terminal_rows(tui_terminal) - 1, trainlog_terminal_columns(tui_terminal) - 1); if (large_layout) { history_ascii_header(); @@ -7539,56 +7400,56 @@ static void screen_history( list_top, 2, list_bottom, - COLS - 3, + trainlog_terminal_columns(tui_terminal) - 3, "SEANCES ENREGISTREES", focus == 1 ); } else { - attron( - A_BOLD | - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + TRAINLOG_TEXT_BOLD | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); - mvprintw( + trainlog_terminal_printf(tui_terminal, 1, 2, " TRAINLOG — Historique " ); - attroff( - A_BOLD | - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + TRAINLOG_TEXT_BOLD | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); } - attron( - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); - mvprintw( - LINES - 2, + trainlog_terminal_printf(tui_terminal, + trainlog_terminal_rows(tui_terminal) - 2, 2, "%.*s", - COLS - 4, + trainlog_terminal_columns(tui_terminal) - 4, large_layout ? "Tab zone ↑↓/PgUp/PgDn liste ←→ menu Entrée ouvrir e modifier 0/Home accueil F1-F4 direct b/Échap retour" : "↑↓ naviguer Entrée détail e modifier b/Échap retour" ); - attroff( - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); if (count == 0U) { - mvprintw( + trainlog_terminal_printf(tui_terminal, first_row + 1, large_layout ? 6 : 4, "Aucune séance." @@ -7625,9 +7486,9 @@ static void screen_history( if (focus == 1 && absolute == selected) { - attron( - A_REVERSE | - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + TRAINLOG_TEXT_REVERSE | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); @@ -7641,7 +7502,7 @@ static void screen_history( display_date ); - mvprintw( + trainlog_terminal_printf(tui_terminal, item_row, item_col, " %-16s %-14s %2zu exercice(s) ", @@ -7655,9 +7516,9 @@ static void screen_history( if (focus == 1 && absolute == selected) { - attroff( - A_REVERSE | - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + TRAINLOG_TEXT_REVERSE | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); @@ -7668,7 +7529,7 @@ static void screen_history( history_scrollbar( first_row, list_bottom - 1, - COLS - 5, + trainlog_terminal_columns(tui_terminal) - 5, selected, count, (size_t)visible_rows @@ -7676,12 +7537,12 @@ static void screen_history( } } - refresh(); - key = getch(); + trainlog_terminal_render(tui_terminal); + key = trainlog_terminal_get_key(tui_terminal); if (large_layout && - (key == '\t' || - key == KEY_BTAB)) { + (key == TRAINLOG_KEY_TAB || + key == TRAINLOG_KEY_SHIFT_TAB)) { focus = focus == 0 ? 1 @@ -7702,19 +7563,19 @@ static void screen_history( if (large_layout && focus == 0) { - if (key == KEY_LEFT) { + if (key == TRAINLOG_KEY_LEFT) { nav_selected = nav_selected > 0 ? nav_selected - 1 : 5; - } else if (key == KEY_RIGHT) { + } else if (key == TRAINLOG_KEY_RIGHT) { nav_selected = nav_selected < 5 ? nav_selected + 1 : 0; } else if ( key == '\n' || - key == KEY_ENTER + key == TRAINLOG_KEY_ENTER ) { if (primary_top_nav_activate( nav_selected @@ -7730,7 +7591,7 @@ static void screen_history( continue; } - if (key == KEY_UP) { + if (key == TRAINLOG_KEY_UP) { selected = selected > 0U ? selected - 1U @@ -7739,7 +7600,7 @@ static void screen_history( continue; } - if (key == KEY_DOWN) { + if (key == TRAINLOG_KEY_DOWN) { selected = selected + 1U < count ? selected + 1U @@ -7748,7 +7609,7 @@ static void screen_history( continue; } - if (key == KEY_PPAGE) { + if (key == TRAINLOG_KEY_PAGE_UP) { size_t jump = (size_t)visible_rows; @@ -7760,7 +7621,7 @@ static void screen_history( continue; } - if (key == KEY_NPAGE) { + if (key == TRAINLOG_KEY_PAGE_DOWN) { size_t jump = (size_t)visible_rows; @@ -7773,7 +7634,7 @@ static void screen_history( } if (key == '\n' || - key == KEY_ENTER) { + key == TRAINLOG_KEY_ENTER) { screen_session_detail( database, sessions[selected].session_id @@ -7804,7 +7665,7 @@ static void body_panel( const char *label ) { - WINDOW *panel; + TrainlogPanel *panel; int height; int width; @@ -7812,17 +7673,15 @@ static void body_panel( left < 0 || bottom <= top || right <= left || - bottom >= LINES || - right >= COLS) { + bottom >= trainlog_terminal_rows(tui_terminal) || + right >= trainlog_terminal_columns(tui_terminal)) { return; } height = bottom - top + 1; width = right - left + 1; - panel = derwin( - stdscr, - height, + panel = tui_panel_create(tui_terminal, height, width, top, left @@ -7832,20 +7691,20 @@ static void body_panel( return; } - box(panel, 0, 0); + tui_panel_box(panel); if (label != NULL && label[0] != '\0' && width > 8) { - wattron( + tui_panel_style_on( panel, - A_BOLD | - trainlog_theme_attribute( + TRAINLOG_TEXT_BOLD | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); - mvwprintw( + tui_panel_print( panel, 0, 2, @@ -7854,18 +7713,17 @@ static void body_panel( label ); - wattroff( + tui_panel_style_off( panel, - A_BOLD | - trainlog_theme_attribute( + TRAINLOG_TEXT_BOLD | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); } - syncok(panel, TRUE); - wsyncup(panel); - delwin(panel); + tui_panel_commit(panel); + tui_panel_destroy(panel); } static void body_short_date( @@ -7961,8 +7819,8 @@ static void body_draw_scrollbar( return; } - attron( - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); @@ -7970,15 +7828,15 @@ static void body_draw_scrollbar( for (row = top; row <= bottom; ++row) { - mvaddch( + trainlog_terminal_draw(tui_terminal, row, column, - ACS_VLINE + 0x2502U ); } - attroff( - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); @@ -7991,22 +7849,22 @@ static void body_draw_scrollbar( (count - 1U) ); - attron( - A_BOLD | - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + TRAINLOG_TEXT_BOLD | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); - mvaddch( + trainlog_terminal_draw(tui_terminal, thumb, column, - ACS_CKBOARD + 0x2593U ); - attroff( - A_BOLD | - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + TRAINLOG_TEXT_BOLD | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); @@ -8447,7 +8305,7 @@ static void body_detail_value( ) { if (present) { - mvprintw( + trainlog_terminal_printf(tui_terminal, row, column, "%-24s %7.1f %s", @@ -8456,7 +8314,7 @@ static void body_detail_value( unit ); } else { - mvprintw( + trainlog_terminal_printf(tui_terminal, row, column, "%-24s %7s", @@ -8477,8 +8335,8 @@ static void screen_body_observation_detail( TrainlogBodyObservationRecord record; char date[9]; bool decorated = - COLS >= 100 && - LINES >= 34; + trainlog_terminal_columns(tui_terminal) >= 100 && + trainlog_terminal_rows(tui_terminal) >= 34; int frame_top = decorated ? 8 : 3; int key; @@ -8503,8 +8361,8 @@ static void screen_body_observation_detail( ); if (decorated) { - erase(); - box(stdscr, 0, 0); + trainlog_terminal_erase(tui_terminal); + trainlog_terminal_box(tui_terminal, 0, 0, trainlog_terminal_rows(tui_terminal) - 1, trainlog_terminal_columns(tui_terminal) - 1); section_ascii_header( ":: D E T A I L C O R P S ::" @@ -8513,30 +8371,30 @@ static void screen_body_observation_detail( focused_panel( frame_top, 2, - LINES - 4, - COLS - 3, + trainlog_terminal_rows(tui_terminal) - 4, + trainlog_terminal_columns(tui_terminal) - 3, page == 0 ? "RELEVE — GENERAL" : "RELEVE — MEMBRES", true ); - attron( - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); - mvprintw( - LINES - 2, + trainlog_terminal_printf(tui_terminal, + trainlog_terminal_rows(tui_terminal) - 2, 2, "%.*s", - COLS - 4, + trainlog_terminal_columns(tui_terminal) - 4, "←→ page e Modifier b/Échap retour" ); - attroff( - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); @@ -8549,22 +8407,22 @@ static void screen_body_observation_detail( body_panel( 3, 2, - LINES - 4, - COLS - 3, + trainlog_terminal_rows(tui_terminal) - 4, + trainlog_terminal_columns(tui_terminal) - 3, page == 0 ? "RELEVE — GENERAL" : "RELEVE — MEMBRES" ); } - attron( - A_BOLD | - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + TRAINLOG_TEXT_BOLD | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); - mvprintw( + trainlog_terminal_printf(tui_terminal, frame_top + 2, 5, "%s page %d/2", @@ -8572,9 +8430,9 @@ static void screen_body_observation_detail( page + 1 ); - attroff( - A_BOLD | - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + TRAINLOG_TEXT_BOLD | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); @@ -8707,8 +8565,8 @@ static void screen_body_observation_detail( ); } - refresh(); - key = getch(); + trainlog_terminal_render(tui_terminal); + key = trainlog_terminal_get_key(tui_terminal); if (key == 'b' || key == 'B' || @@ -8716,8 +8574,8 @@ static void screen_body_observation_detail( return; } - if (key == KEY_LEFT || - key == KEY_RIGHT) { + if (key == TRAINLOG_KEY_LEFT || + key == TRAINLOG_KEY_RIGHT) { page = page == 0 ? 1 @@ -9122,7 +8980,7 @@ static bool body_analytics_profile_prompt( TRAINLOG_COLOR_ERROR ); - refresh(); + trainlog_terminal_render(tui_terminal); } profile->formula = @@ -9257,7 +9115,7 @@ static void body_analytics_value( ) { if (present) { - mvprintw( + trainlog_terminal_printf(tui_terminal, row, 6, "%-25s %8.2f %s", @@ -9268,7 +9126,7 @@ static void body_analytics_value( : "" ); } else { - mvprintw( + trainlog_terminal_printf(tui_terminal, row, 6, "%-25s %8s", @@ -9352,23 +9210,21 @@ static void screen_body_analytics( ); bool decorated = - COLS >= 100 && - LINES >= 30; + trainlog_terminal_columns(tui_terminal) >= 100 && + trainlog_terminal_rows(tui_terminal) >= 30; int panel_top = decorated ? 8 : 3; int panel_bottom = - LINES - 4; + trainlog_terminal_rows(tui_terminal) - 4; int key; - erase(); - box( - stdscr, - 0, - 0 - ); + trainlog_terminal_erase(tui_terminal); + trainlog_terminal_box(tui_terminal, 0, 0, + trainlog_terminal_rows(tui_terminal) - 1, + trainlog_terminal_columns(tui_terminal) - 1); if (decorated) { section_ascii_header( @@ -9379,7 +9235,7 @@ static void screen_body_analytics( panel_top, 2, panel_bottom, - COLS - 3, + trainlog_terminal_columns(tui_terminal) - 3, page == 0 ? "COMPOSITION ET TENDANCE" : "PROPORTIONS ET SYMETRIE", @@ -9395,41 +9251,41 @@ static void screen_body_analytics( panel_top, 2, panel_bottom, - COLS - 3, + trainlog_terminal_columns(tui_terminal) - 3, page == 0 ? "COMPOSITION" : "PROPORTIONS" ); } - attron( - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); - mvprintw( - LINES - 2, + trainlog_terminal_printf(tui_terminal, + trainlog_terminal_rows(tui_terminal) - 2, 2, "%.*s", - COLS - 4, + trainlog_terminal_columns(tui_terminal) - 4, "←→ page p profil estimation b/Échap retour" ); - attroff( - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); if (count == 0U) { - mvprintw( + trainlog_terminal_printf(tui_terminal, panel_top + 2, 6, "Aucun relevé corporel." ); - mvprintw( + trainlog_terminal_printf(tui_terminal, panel_top + 4, 6, "Ajoutez d'abord un relevé réel." @@ -9442,29 +9298,29 @@ static void screen_body_analytics( date ); - attron( - A_BOLD | - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + TRAINLOG_TEXT_BOLD | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); - mvprintw( + trainlog_terminal_printf(tui_terminal, panel_top + 2, 6, "Dernier relevé : %s", date ); - attroff( - A_BOLD | - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + TRAINLOG_TEXT_BOLD | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); if (has_profile) { - mvprintw( + trainlog_terminal_printf(tui_terminal, panel_top + 4, 6, "Profil estimation : %s · %.1f cm", @@ -9475,20 +9331,20 @@ static void screen_body_analytics( profile.height_cm ); } else { - attron( - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_WARNING ) ); - mvprintw( + trainlog_terminal_printf(tui_terminal, panel_top + 4, 6, "Profil estimation non configuré · p pour configurer." ); - attroff( - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_WARNING ) ); @@ -9543,7 +9399,7 @@ static void screen_body_analytics( ->body_weight_kg : 0.0; - mvprintw( + trainlog_terminal_printf(tui_terminal, panel_top + 10, 6, "Poids : %.1f kg · variation depuis 1er poids : %+.1f kg", @@ -9551,7 +9407,7 @@ static void screen_body_analytics( delta ); } else { - mvprintw( + trainlog_terminal_printf(tui_terminal, panel_top + 10, 6, "Poids : —" @@ -9568,7 +9424,7 @@ static void screen_body_analytics( ->waist_cm : 0.0; - mvprintw( + trainlog_terminal_printf(tui_terminal, panel_top + 11, 6, "Tour de taille : %.1f cm · variation : %+.1f cm", @@ -9576,7 +9432,7 @@ static void screen_body_analytics( delta ); } else { - mvprintw( + trainlog_terminal_printf(tui_terminal, panel_top + 11, 6, "Tour de taille : —" @@ -9589,7 +9445,7 @@ static void screen_body_analytics( .has_body_fat_estimate && has_oldest_estimate ) { - mvprintw( + trainlog_terminal_printf(tui_terminal, panel_top + 12, 6, "Variation graisse estimée : %+.2f point(s)", @@ -9600,22 +9456,22 @@ static void screen_body_analytics( ); } - attron( - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_WARNING ) ); - mvprintw( + trainlog_terminal_printf(tui_terminal, panel_top + 14, 6, "%.*s", - COLS - 14, + trainlog_terminal_columns(tui_terminal) - 14, "Estimation anthropométrique : tendance utile, pas mesure directe de composition." ); - attroff( - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_WARNING ) ); @@ -9659,7 +9515,7 @@ static void screen_body_analytics( "" ); - mvprintw( + trainlog_terminal_printf(tui_terminal, panel_top + 8, 6, "ASYMETRIE GAUCHE / DROITE" @@ -9717,29 +9573,29 @@ static void screen_body_analytics( "%" ); - attron( - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); - mvprintw( + trainlog_terminal_printf(tui_terminal, panel_top + 16, 6, "%.*s", - COLS - 14, + trainlog_terminal_columns(tui_terminal) - 14, "Ratios et asymétries sont descriptifs : Trainlog ne les transforme pas en diagnostic." ); - attroff( - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); } - refresh(); - key = getch(); + trainlog_terminal_render(tui_terminal); + key = trainlog_terminal_get_key(tui_terminal); if ( key == 'b' || @@ -9750,8 +9606,8 @@ static void screen_body_analytics( } if ( - key == KEY_LEFT || - key == KEY_RIGHT + key == TRAINLOG_KEY_LEFT || + key == TRAINLOG_KEY_RIGHT ) { page = page == 0 @@ -9794,8 +9650,8 @@ static void screen_body_analytics( TRAINLOG_COLOR_SUCCESS ); - refresh(); - (void)getch(); + trainlog_terminal_render(tui_terminal); + (void)trainlog_terminal_get_key(tui_terminal); } } } @@ -9822,8 +9678,8 @@ static void screen_body( size_t index; bool large_layout = - COLS >= 100 && - LINES >= 30; + trainlog_terminal_columns(tui_terminal) >= 100 && + trainlog_terminal_rows(tui_terminal) >= 30; int graph_top = large_layout ? 11 : 3; @@ -9835,7 +9691,7 @@ static void screen_body( graph_bottom + 1; int list_bottom = - LINES - 4; + trainlog_terminal_rows(tui_terminal) - 4; int first_row = list_top + 1; @@ -9883,8 +9739,8 @@ static void screen_body( } if (large_layout) { - erase(); - box(stdscr, 0, 0); + trainlog_terminal_erase(tui_terminal); + trainlog_terminal_box(tui_terminal, 0, 0, trainlog_terminal_rows(tui_terminal) - 1, trainlog_terminal_columns(tui_terminal) - 1); section_ascii_header( ":: C O R P S ::" @@ -9896,22 +9752,22 @@ static void screen_body( focus == 0 ); - attron( - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); - mvprintw( - LINES - 2, + trainlog_terminal_printf(tui_terminal, + trainlog_terminal_rows(tui_terminal) - 2, 2, "%.*s", - COLS - 4, + trainlog_terminal_columns(tui_terminal) - 4, "Tab zone ↑↓/PgUp/PgDn relevés ←→ menu Entrée détail e Modifier a ajouter v analyse g vue globale 0/Home accueil F1-F4 direct" ); - attroff( - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); @@ -9926,12 +9782,12 @@ static void screen_body( graph_top, 2, graph_bottom, - COLS - 3, + trainlog_terminal_columns(tui_terminal) - 3, "EVOLUTION DU POIDS" ); if (weight_count == 0U) { - mvprintw( + trainlog_terminal_printf(tui_terminal, graph_top + 3, 6, "Aucune donnée de poids." @@ -9953,7 +9809,7 @@ static void screen_body( list_top, 2, list_bottom, - COLS - 3, + trainlog_terminal_columns(tui_terminal) - 3, "RELEVES ENREGISTRES", focus == 1 ); @@ -9962,13 +9818,13 @@ static void screen_body( list_top, 2, list_bottom, - COLS - 3, + trainlog_terminal_columns(tui_terminal) - 3, "RELEVES ENREGISTRES" ); } if (count == 0U) { - mvprintw( + trainlog_terminal_printf(tui_terminal, first_row + 1, 6, "Aucun relevé. Appuyez sur a pour en ajouter un." @@ -10002,28 +9858,28 @@ static void screen_body( if (focus == 1 && absolute == selected) { - attron( - A_REVERSE | - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + TRAINLOG_TEXT_REVERSE | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); } - mvprintw( + trainlog_terminal_printf(tui_terminal, row, 5, " %-8s %.*s ", date, - COLS - 20, + trainlog_terminal_columns(tui_terminal) - 20, summary ); if (focus == 1 && absolute == selected) { - attroff( - A_REVERSE | - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + TRAINLOG_TEXT_REVERSE | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); @@ -10033,18 +9889,18 @@ static void screen_body( body_draw_scrollbar( first_row, list_bottom - 1, - COLS - 5, + trainlog_terminal_columns(tui_terminal) - 5, selected, count, (size_t)visible_rows ); - refresh(); - key = getch(); + trainlog_terminal_render(tui_terminal); + key = trainlog_terminal_get_key(tui_terminal); if (large_layout && - (key == '\t' || - key == KEY_BTAB)) { + (key == TRAINLOG_KEY_TAB || + key == TRAINLOG_KEY_SHIFT_TAB)) { focus = focus == 0 ? 1 @@ -10065,19 +9921,19 @@ static void screen_body( if (large_layout && focus == 0) { - if (key == KEY_LEFT) { + if (key == TRAINLOG_KEY_LEFT) { nav_selected = nav_selected > 0 ? nav_selected - 1 : 5; - } else if (key == KEY_RIGHT) { + } else if (key == TRAINLOG_KEY_RIGHT) { nav_selected = nav_selected < 5 ? nav_selected + 1 : 0; } else if ( key == '\n' || - key == KEY_ENTER + key == TRAINLOG_KEY_ENTER ) { if (primary_top_nav_activate( nav_selected @@ -10089,7 +9945,7 @@ static void screen_body( continue; } - if (key == KEY_UP && + if (key == TRAINLOG_KEY_UP && count > 0U) { selected = selected > 0U @@ -10099,7 +9955,7 @@ static void screen_body( continue; } - if (key == KEY_DOWN && + if (key == TRAINLOG_KEY_DOWN && count > 0U) { selected = selected + 1U < count @@ -10109,7 +9965,7 @@ static void screen_body( continue; } - if (key == KEY_PPAGE && + if (key == TRAINLOG_KEY_PAGE_UP && count > 0U) { size_t jump = (size_t)visible_rows; @@ -10122,7 +9978,7 @@ static void screen_body( continue; } - if (key == KEY_NPAGE && + if (key == TRAINLOG_KEY_PAGE_DOWN && count > 0U) { size_t jump = (size_t)visible_rows; @@ -10136,7 +9992,7 @@ static void screen_body( } if ((key == '\n' || - key == KEY_ENTER) && + key == TRAINLOG_KEY_ENTER) && count > 0U) { screen_body_observation_detail( database, @@ -10184,8 +10040,8 @@ static void screen_body( database ); - refresh(); - (void)getch(); + trainlog_terminal_render(tui_terminal); + (void)trainlog_terminal_get_key(tui_terminal); } } } @@ -10657,8 +10513,8 @@ static void screen_sync_run_detail( for (;;) { bool large_layout = - COLS >= 100 && - LINES >= 30; + trainlog_terminal_columns(tui_terminal) >= 100 && + trainlog_terminal_rows(tui_terminal) >= 30; int panel_top = large_layout @@ -10666,7 +10522,7 @@ static void screen_sync_run_detail( : 3; int panel_bottom = - LINES - 4; + trainlog_terminal_rows(tui_terminal) - 4; int first_row = panel_top + 2; @@ -10682,12 +10538,10 @@ static void screen_sync_run_detail( return; } - erase(); - box( - stdscr, - 0, - 0 - ); + trainlog_terminal_erase(tui_terminal); + trainlog_terminal_box(tui_terminal, 0, 0, + trainlog_terminal_rows(tui_terminal) - 1, + trainlog_terminal_columns(tui_terminal) - 1); if (large_layout) { section_ascii_header( @@ -10698,7 +10552,7 @@ static void screen_sync_run_detail( panel_top, 2, panel_bottom, - COLS - 3, + trainlog_terminal_columns(tui_terminal) - 3, "DETAIL SYNCHRONISATION", true ); @@ -10716,14 +10570,14 @@ static void screen_sync_run_detail( line_count; ++index ) { - mvprintw( + trainlog_terminal_printf(tui_terminal, first_row + (int)index, large_layout ? 5 : 4, "%.*s", - COLS - + trainlog_terminal_columns(tui_terminal) - ( large_layout ? 10 @@ -10736,53 +10590,53 @@ static void screen_sync_run_detail( ); } - attron( - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); - mvprintw( - LINES - 2, + trainlog_terminal_printf(tui_terminal, + trainlog_terminal_rows(tui_terminal) - 2, 2, "%.*s", - COLS - 4, + trainlog_terminal_columns(tui_terminal) - 4, "↑↓ défiler PgUp/PgDn page b/Échap retour" ); - attroff( - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); - refresh(); - key = getch(); + trainlog_terminal_render(tui_terminal); + key = trainlog_terminal_get_key(tui_terminal); if ( key == 'b' || key == 'B' || key == 27 || key == '\n' || - key == KEY_ENTER + key == TRAINLOG_KEY_ENTER ) { return; } if ( - key == KEY_UP && + key == TRAINLOG_KEY_UP && offset > 0U ) { --offset; } else if ( - key == KEY_DOWN && + key == TRAINLOG_KEY_DOWN && offset + (size_t)visible < line_count ) { ++offset; } else if ( - key == KEY_PPAGE + key == TRAINLOG_KEY_PAGE_UP ) { size_t jump = (size_t)visible; @@ -10792,7 +10646,7 @@ static void screen_sync_run_detail( ? offset - jump : 0U; } else if ( - key == KEY_NPAGE + key == TRAINLOG_KEY_PAGE_DOWN ) { size_t jump = (size_t)visible; @@ -10861,8 +10715,8 @@ static void screen_sync( size_t history_count = 0U; bool large_layout = - COLS >= 100 && - LINES >= 30; + trainlog_terminal_columns(tui_terminal) >= 100 && + trainlog_terminal_rows(tui_terminal) >= 30; int key; @@ -10889,12 +10743,10 @@ static void screen_sync( history_count - 1U; } - erase(); - box( - stdscr, - 0, - 0 - ); + trainlog_terminal_erase(tui_terminal); + trainlog_terminal_box(tui_terminal, 0, 0, + trainlog_terminal_rows(tui_terminal) - 1, + trainlog_terminal_columns(tui_terminal) - 1); if (large_layout) { size_t index; @@ -10903,7 +10755,7 @@ static void screen_sync( int history_top = 18; int history_bottom = - LINES - 4; + trainlog_terminal_rows(tui_terminal) - 4; int visible_rows = history_bottom - @@ -10924,7 +10776,7 @@ static void screen_sync( 11, 2, 16, - COLS - 3, + trainlog_terminal_columns(tui_terminal) - 3, "APPAREIL CONNECTE", false ); @@ -10935,13 +10787,13 @@ static void screen_sync( device.connected && device.storage_ready ) { - mvprintw( + trainlog_terminal_printf(tui_terminal, 12, 5, "✓ MTP direct connecté" ); - mvprintw( + trainlog_terminal_printf(tui_terminal, 13, 5, "%s %s", @@ -10949,7 +10801,7 @@ static void screen_sync( device.device.model ); - mvprintw( + trainlog_terminal_printf(tui_terminal, 14, 5, "Stockage interne : %.2f GiB libres / %.2f GiB", @@ -10966,13 +10818,13 @@ static void screen_sync( probe_status == TRAINLOG_STATUS_CONFLICT ) { - mvprintw( + trainlog_terminal_printf(tui_terminal, 13, 5, "Service de synchronisation occupé." ); } else { - mvprintw( + trainlog_terminal_printf(tui_terminal, 13, 5, "Aucun appareil MTP Trainlog détecté." @@ -10983,7 +10835,7 @@ static void screen_sync( history_top, 2, history_bottom, - COLS - 3, + trainlog_terminal_columns(tui_terminal) - 3, "HISTORIQUE DES SYNCHRONISATIONS", focus == 1 ); @@ -10991,7 +10843,7 @@ static void screen_sync( if ( history_count == 0U ) { - mvprintw( + trainlog_terminal_printf(tui_terminal, history_top + 2, 5, "Aucune synchronisation enregistrée." @@ -11031,15 +10883,15 @@ static void screen_sync( focus == 1 && absolute == selected ) { - attron( - A_REVERSE | - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + TRAINLOG_TEXT_REVERSE | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); } - mvprintw( + trainlog_terminal_printf(tui_terminal, row, 5, " %-16s %c %-*.*s ", @@ -11049,8 +10901,8 @@ static void screen_sync( .success ? '+' : '!', - COLS - 28, - COLS - 28, + trainlog_terminal_columns(tui_terminal) - 28, + trainlog_terminal_columns(tui_terminal) - 28, history[absolute] .summary ); @@ -11059,9 +10911,9 @@ static void screen_sync( focus == 1 && absolute == selected ) { - attroff( - A_REVERSE | - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + TRAINLOG_TEXT_REVERSE | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); @@ -11069,22 +10921,22 @@ static void screen_sync( } } - attron( - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); - mvprintw( - LINES - 2, + trainlog_terminal_printf(tui_terminal, + trainlog_terminal_rows(tui_terminal) - 2, 2, "%.*s", - COLS - 4, + trainlog_terminal_columns(tui_terminal) - 4, "Tab zone ←→ menu ↑↓ historique Entrée détail s synchroniser r actualiser b/Échap retour" ); - attroff( - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + trainlog_theme_style( TRAINLOG_COLOR_MUTED ) ); @@ -11099,7 +10951,7 @@ static void screen_sync( TRAINLOG_STATUS_OK && device.connected ) { - mvprintw( + trainlog_terminal_printf(tui_terminal, 4, 4, "✓ %s %s", @@ -11107,7 +10959,7 @@ static void screen_sync( device.device.model ); } else { - mvprintw( + trainlog_terminal_printf(tui_terminal, 4, 4, "Aucun appareil MTP." @@ -11117,7 +10969,7 @@ static void screen_sync( if ( history_count == 0U ) { - mvprintw( + trainlog_terminal_printf(tui_terminal, 7, 4, "Aucune synchronisation." @@ -11138,15 +10990,15 @@ static void screen_sync( if ( index == selected ) { - attron( - A_REVERSE | - trainlog_theme_attribute( + trainlog_terminal_style_on(tui_terminal, + TRAINLOG_TEXT_REVERSE | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); } - mvprintw( + trainlog_terminal_printf(tui_terminal, 7 + (int)index, 4, "%-16s %c %.*s", @@ -11156,7 +11008,7 @@ static void screen_sync( .success ? '+' : '!', - COLS - 25, + trainlog_terminal_columns(tui_terminal) - 25, history[index] .summary ); @@ -11164,9 +11016,9 @@ static void screen_sync( if ( index == selected ) { - attroff( - A_REVERSE | - trainlog_theme_attribute( + trainlog_terminal_style_off(tui_terminal, + TRAINLOG_TEXT_REVERSE | + trainlog_theme_style( TRAINLOG_COLOR_ACCENT ) ); @@ -11175,8 +11027,8 @@ static void screen_sync( } } - refresh(); - key = getch(); + trainlog_terminal_render(tui_terminal); + key = trainlog_terminal_get_key(tui_terminal); if ( key == 's' || @@ -11190,7 +11042,7 @@ static void screen_sync( TRAINLOG_COLOR_WARNING ); - refresh(); + trainlog_terminal_render(tui_terminal); status = trainlog_sync_run( @@ -11217,8 +11069,8 @@ static void screen_sync( ); } - refresh(); - (void)getch(); + trainlog_terminal_render(tui_terminal); + (void)trainlog_terminal_get_key(tui_terminal); refresh_device = true; continue; @@ -11243,8 +11095,8 @@ static void screen_sync( if ( large_layout && ( - key == '\t' || - key == KEY_BTAB + key == TRAINLOG_KEY_TAB || + key == TRAINLOG_KEY_SHIFT_TAB ) ) { focus = @@ -11260,14 +11112,14 @@ static void screen_sync( focus == 0 ) { if ( - key == KEY_LEFT + key == TRAINLOG_KEY_LEFT ) { nav_selected = nav_selected > 0 ? nav_selected - 1 : 5; } else if ( - key == KEY_RIGHT + key == TRAINLOG_KEY_RIGHT ) { nav_selected = nav_selected < 5 @@ -11275,11 +11127,9 @@ static void screen_sync( : 0; } else if ( key == '\n' || - key == KEY_ENTER + key == TRAINLOG_KEY_ENTER ) { - if ( - nav_selected == 5 - ) { + if (nav_selected == 5) { focus = 1; } else if ( primary_top_nav_activate( @@ -11297,7 +11147,7 @@ static void screen_sync( history_count > 0U && ( key == '\n' || - key == KEY_ENTER + key == TRAINLOG_KEY_ENTER ) ) { if ( @@ -11314,7 +11164,7 @@ static void screen_sync( } if ( - key == KEY_UP && + key == TRAINLOG_KEY_UP && history_count > 0U ) { selected = @@ -11322,7 +11172,7 @@ static void screen_sync( ? selected - 1U : history_count - 1U; } else if ( - key == KEY_DOWN && + key == TRAINLOG_KEY_DOWN && history_count > 0U ) { selected = @@ -11332,18 +11182,18 @@ static void screen_sync( : 0U; } else if ( key == '0' || - key == KEY_HOME + key == TRAINLOG_KEY_HOME ) { return; } else if ( key == '1' || - key == KEY_F(1) || + key == TRAINLOG_KEY_F1 || key == '2' || - key == KEY_F(2) || + key == TRAINLOG_KEY_F2 || key == '3' || - key == KEY_F(3) || + key == TRAINLOG_KEY_F3 || key == '4' || - key == KEY_F(4) + key == TRAINLOG_KEY_F4 ) { if ( primary_top_nav_forward( @@ -11364,32 +11214,29 @@ int trainlog_tui_run(TrainlogDatabase *database) (void)setlocale(LC_ALL, ""); - if (initscr() == NULL) { + tui_terminal = trainlog_terminal_create(); + if (tui_terminal == NULL) { return 1; } - cbreak(); - noecho(); - keypad(stdscr, true); - curs_set(0); - trainlog_theme_initialize(); + trainlog_terminal_cursor_visible(tui_terminal, false); for (;;) { DashboardAction action; - if (LINES < 20 || COLS < 72) { + if (trainlog_terminal_rows(tui_terminal) < 20 || trainlog_terminal_columns(tui_terminal) < 72) { int key; - erase(); - mvprintw( + trainlog_terminal_erase(tui_terminal); + trainlog_terminal_printf(tui_terminal, 1, 2, "Terminal trop petit — minimum 72x20." ); - mvprintw(3, 2, "q pour quitter"); - refresh(); + trainlog_terminal_printf(tui_terminal, 3, 2, "q pour quitter"); + trainlog_terminal_render(tui_terminal); - key = getch(); + key = trainlog_terminal_get_key(tui_terminal); if (key == 'q' || key == 'Q') { break; } @@ -11415,13 +11262,15 @@ int trainlog_tui_run(TrainlogDatabase *database) screen_sync(database); break; case DASHBOARD_QUIT: - endwin(); + trainlog_terminal_destroy(tui_terminal); + tui_terminal = NULL; return 0; default: break; } } - endwin(); + trainlog_terminal_destroy(tui_terminal); + tui_terminal = NULL; return 0; } diff --git a/tui/tests/test_terminal_input.c b/tui/tests/test_terminal_input.c new file mode 100644 index 0000000..599900c --- /dev/null +++ b/tui/tests/test_terminal_input.c @@ -0,0 +1,90 @@ +/** + * @file test_terminal_input.c + * @brief Deterministic tests for the Notcurses input lifecycle boundary. + */ + +#include +#include + +#include + +#include "trainlog/terminal.h" + +#define CHECK(condition) \ + do { \ + if (!(condition)) { \ + (void)fprintf( \ + stderr, \ + "CHECK failed at %s:%d: %s\n", \ + __FILE__, \ + __LINE__, \ + #condition \ + ); \ + return false; \ + } \ + } while (0) + +static bool test_event_type_policy(void) +{ + int key = 12345; + + CHECK(trainlog_terminal_translate_input( + NCKEY_RIGHT, TRAINLOG_INPUT_UNKNOWN, false, &key)); + CHECK(key == TRAINLOG_KEY_RIGHT); + + CHECK(trainlog_terminal_translate_input( + NCKEY_RIGHT, TRAINLOG_INPUT_PRESS, false, &key)); + CHECK(key == TRAINLOG_KEY_RIGHT); + + CHECK(trainlog_terminal_translate_input( + NCKEY_RIGHT, TRAINLOG_INPUT_REPEAT, false, &key)); + CHECK(key == TRAINLOG_KEY_RIGHT); + + key = 12345; + CHECK(!trainlog_terminal_translate_input( + NCKEY_RIGHT, TRAINLOG_INPUT_RELEASE, false, &key)); + CHECK(key == 12345); + + CHECK(!trainlog_terminal_translate_input( + NCKEY_RIGHT, (TrainlogInputEventType)99, false, &key)); + CHECK(key == 12345); + + return true; +} + +static bool test_key_translation(void) +{ + int key = TRAINLOG_KEY_NONE; + + CHECK(trainlog_terminal_translate_input( + NCKEY_TAB, TRAINLOG_INPUT_PRESS, false, &key)); + CHECK(key == TRAINLOG_KEY_TAB); + + CHECK(trainlog_terminal_translate_input( + NCKEY_TAB, TRAINLOG_INPUT_PRESS, true, &key)); + CHECK(key == TRAINLOG_KEY_SHIFT_TAB); + + CHECK(trainlog_terminal_translate_input( + 0x00e9U, TRAINLOG_INPUT_REPEAT, false, &key)); + CHECK(key == 0x00e9); + + CHECK(!trainlog_terminal_translate_input( + 0U, TRAINLOG_INPUT_PRESS, false, &key)); + CHECK(!trainlog_terminal_translate_input( + UINT32_MAX, TRAINLOG_INPUT_PRESS, false, &key)); + CHECK(!trainlog_terminal_translate_input( + NCKEY_RIGHT, TRAINLOG_INPUT_PRESS, false, NULL)); + + return true; +} + +int main(void) +{ + CHECK(test_event_type_policy()); + (void)printf("PASS terminal_input_event_type_policy\n"); + + CHECK(test_key_translation()); + (void)printf("PASS terminal_input_key_translation\n"); + + return 0; +}