Modernize TUI and harden Android workout capture

This commit is contained in:
fy59 2026-09-07 16:14:01 +02:00
parent 444a4d2d8d
commit c9c4d3b9ce
37 changed files with 5024 additions and 1414 deletions

View file

@ -67,6 +67,13 @@ Display names are not identities.
Import and synchronization paths must remain idempotent by stable IDs. 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 ## 5. Desktop implementation
The desktop core is C17. The desktop core is C17.
@ -82,17 +89,15 @@ Current primary dependencies:
- Ninja; - Ninja;
- one active desktop terminal backend. - one active desktop terminal backend.
For the authorized `TUI_NOTCURSES_V1` tranche: For the completed `TUI_NOTCURSES_V1` infrastructure checkpoint:
```text ```text
legacy backend = ncursesw legacy backend = ncursesw (historical only)
target backend = Notcurses active backend = Notcurses
``` ```
During the migration, ncursesw may remain only as the pre-migration `TUI_NOTCURSES_V1=PASS`. Active desktop TUI code and build wiring use
implementation being replaced. Once `TUI_NOTCURSES_V1=PASS`, active desktop TUI Notcurses and do not retain ncursesw as an unused compatibility backend.
code and build wiring must use Notcurses and must not retain ncursesw as an
unused permanent compatibility backend.
Business logic, persistence, transport, and rendering remain separated. 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 The Android UI is driven by exercise metadata, never by exercise-name
heuristics. 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 ## 7. Synchronization architecture
Desktop access to Android uses physical-device discovery with `libudev` and Desktop access to Android uses physical-device discovery with `libudev` and

View file

@ -9,6 +9,15 @@ Detailed implementation chronology remains available in Git history and
### Added ### 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; - native Kotlin/Compose Android capture client;
- Android local exercise, session, continuous-activity, and body persistence; - Android local exercise, session, continuous-activity, and body persistence;
- C17/ncursesw desktop TUI with direct session entry and durable SQLite history; - 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 ### 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; - desktop SQLite schema evolved to v5;
- schema v5 permits targetless set-session rows for actual-only mobile data; - schema v5 permits targetless set-session rows for actual-only mobile data;
- heterogeneous performed sets are preserved without inventing a uniform target; - heterogeneous performed sets are preserved without inventing a uniform target;
@ -51,6 +68,11 @@ Detailed implementation chronology remains available in Git history and
### Fixed ### 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 importer call after desktop schema v5 migration;
- stale schema-v4 guard in the PC catalog exporter; - stale schema-v4 guard in the PC catalog exporter;
- missing `sy` prefix support in the UUID creator; - missing `sy` prefix support in the UUID creator;
@ -67,10 +89,14 @@ Current validated baseline:
TRAINLOG_FORMAT_V1=FROZEN TRAINLOG_FORMAT_V1=FROZEN
DESKTOP_SCHEMA_V5=PASS DESKTOP_SCHEMA_V5=PASS
DESKTOP_TESTS=19/19 PASS DESKTOP_TESTS=22/22 PASS
ANDROID_BUILD=PASS ANDROID_BUILD=PASS
ANDROID_LOCAL_WORKFLOWS=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 USB_MTP_DETECTION=PASS
MTP_ROUNDTRIP=PASS MTP_ROUNDTRIP=PASS

View file

@ -4,7 +4,7 @@ Trainlog is a local-first workout and body-tracking system with two user
interfaces: interfaces:
- a native Android application optimized for fast data entry during training; - 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. statistics, and synchronization.
The desktop SQLite database is the canonical long-term history. Android keeps The desktop SQLite database is the canonical long-term history. Android keeps
@ -18,6 +18,10 @@ TRAINLOG_FORMAT_V1=FROZEN
DESKTOP_SCHEMA_V5=PASS DESKTOP_SCHEMA_V5=PASS
ANDROID_LOCAL_WORKFLOWS=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 VARIABLE_REPETITION_SETS=PASS
CONTINUOUS_ACTIVITY_TRACKING=PASS CONTINUOUS_ACTIVITY_TRACKING=PASS
@ -29,7 +33,7 @@ ANDROID_TRIGGERED_SYNC=PASS
ANDROID_SYNC_RECEIPT=PASS ANDROID_SYNC_RECEIPT=PASS
BIDIRECTIONAL_SYNC_V1=PASS BIDIRECTIONAL_SYNC_V1=PASS
DESKTOP_TESTS=21/21 PASS DESKTOP_TESTS=22/22 PASS
ANDROID_BUILD=PASS ANDROID_BUILD=PASS
``` ```
@ -100,7 +104,7 @@ Actual repetition sets are stored independently. Compact input supports:
```text ```text
android/ native Kotlin/Compose Android client android/ native Kotlin/Compose Android client
tui/ C17 ncursesw desktop application and core tui/ C17 Notcurses desktop application and core
docs/ canonical project documentation docs/ canonical project documentation
format/ frozen Trainlog JSON v1 schema material format/ frozen Trainlog JSON v1 schema material
examples/ valid frozen-format examples examples/ valid frozen-format examples
@ -123,6 +127,22 @@ git diff --check
## Android build ## 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_<uuid-v4>` 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 The local Android SDK is intentionally not committed. Configure it with either
`ANDROID_HOME` or `android/local.properties`. `ANDROID_HOME` or `android/local.properties`.
@ -226,5 +246,5 @@ BODY_ANALYTICS_V1=PASS
BODY_COMPOSITION_ESTIMATE=PASS BODY_COMPOSITION_ESTIMATE=PASS
BODY_PROPORTION_RATIOS=PASS BODY_PROPORTION_RATIOS=PASS
BODY_SYMMETRY_ANALYTICS=PASS BODY_SYMMETRY_ANALYTICS=PASS
DESKTOP_TESTS=21/21 PASS DESKTOP_TESTS=22/22 PASS
``` ```

View file

@ -14,6 +14,8 @@ android {
versionCode = 1 versionCode = 1
versionName = "0.1.0" versionName = "0.1.0"
testInstrumentationRunner =
"androidx.test.runner.AndroidJUnitRunner"
} }
compileOptions { compileOptions {
@ -24,6 +26,19 @@ android {
buildFeatures { buildFeatures {
compose = true compose = true
} }
testOptions {
unitTests.isIncludeAndroidResources = true
unitTests.all {
it.systemProperty(
"user.home",
layout.buildDirectory
.get()
.asFile
.absolutePath,
)
}
}
} }
dependencies { dependencies {
@ -31,6 +46,7 @@ dependencies {
platform("androidx.compose:compose-bom:2026.08.00") platform("androidx.compose:compose-bom:2026.08.00")
implementation(composeBom) implementation(composeBom)
androidTestImplementation(composeBom)
implementation( implementation(
"androidx.activity:activity-compose:1.13.0" "androidx.activity:activity-compose:1.13.0"
@ -53,4 +69,12 @@ dependencies {
debugImplementation( debugImplementation(
"androidx.compose.ui:ui-tooling" "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")
} }

View file

@ -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
}
}

View file

@ -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()
}
}

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name="com.labfytools.trainlog.ui.DraftUiTestActivity"
android:exported="false" />
</application>
</manifest>

View file

@ -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<String?>(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()
}
}

View file

@ -70,3 +70,24 @@ data class NewExerciseProfile(
return true 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()
}

View file

@ -36,6 +36,27 @@ data class SessionDraft(
val sessionType: SessionType = SessionType.TRAINING, 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<SessionExerciseDraft> = emptyList(),
val sessionType: SessionType = SessionType.TRAINING,
val form: SessionDraftForm = SessionDraftForm(),
val updatedAt: String = "",
)
data class SessionSummary( data class SessionSummary(
val sessionId: String, val sessionId: String,
val startedAt: String, val startedAt: String,

View file

@ -16,8 +16,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.labfytools.trainlog.data.CreateExerciseResult import com.labfytools.trainlog.data.CreateExerciseResult
import com.labfytools.trainlog.data.EditExerciseResult
import com.labfytools.trainlog.data.TrainlogRepository import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.model.ExerciseDataFields 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.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.TrackingMode import com.labfytools.trainlog.model.TrackingMode
@ -70,6 +73,28 @@ fun ExerciseScreen(
) )
} }
var editedExercise by
remember {
mutableStateOf<ExerciseProfile?>(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( TrainlogScreen(
subtitle = "E X E R C I C E" subtitle = "E X E R C I C E"
) { ) {
@ -91,7 +116,12 @@ fun ExerciseScreen(
) )
TrainlogFrame( TrainlogFrame(
title = "NOUVEL EXERCICE" title =
if (editedExercise == null) {
"NOUVEL EXERCICE"
} else {
"MODIFIER L'EXERCICE"
}
) { ) {
TrainlogField( TrainlogField(
label = "Nom", label = "Nom",
@ -110,7 +140,9 @@ fun ExerciseScreen(
selected = selected =
recordingMode == recordingMode ==
RecordingMode.SETS, RecordingMode.SETS,
enabled = !profileLocked,
onClick = { onClick = {
if (profileLocked) return@TrainlogChoice
recordingMode = recordingMode =
RecordingMode.SETS RecordingMode.SETS
@ -125,7 +157,9 @@ fun ExerciseScreen(
selected = selected =
recordingMode == recordingMode ==
RecordingMode.CONTINUOUS, RecordingMode.CONTINUOUS,
enabled = !profileLocked,
onClick = { onClick = {
if (profileLocked) return@TrainlogChoice
recordingMode = recordingMode =
RecordingMode.CONTINUOUS RecordingMode.CONTINUOUS
@ -150,7 +184,9 @@ fun ExerciseScreen(
selected = selected =
trackingMode == trackingMode ==
TrackingMode.REPS, TrackingMode.REPS,
enabled = !profileLocked,
onClick = { onClick = {
if (profileLocked) return@TrainlogChoice
trackingMode = trackingMode =
TrackingMode.REPS TrackingMode.REPS
@ -164,7 +200,9 @@ fun ExerciseScreen(
selected = selected =
trackingMode == trackingMode ==
TrackingMode.DURATION, TrackingMode.DURATION,
enabled = !profileLocked,
onClick = { onClick = {
if (profileLocked) return@TrainlogChoice
trackingMode = trackingMode =
TrackingMode.DURATION TrackingMode.DURATION
@ -184,7 +222,9 @@ fun ExerciseScreen(
TrainlogChoice( TrainlogChoice(
label = "Vitesse", label = "Vitesse",
selected = speed, selected = speed,
enabled = !profileLocked,
onClick = { onClick = {
if (profileLocked) return@TrainlogChoice
speed = !speed speed = !speed
message = null message = null
}, },
@ -193,7 +233,9 @@ fun ExerciseScreen(
TrainlogChoice( TrainlogChoice(
label = "Distance", label = "Distance",
selected = distance, selected = distance,
enabled = !profileLocked,
onClick = { onClick = {
if (profileLocked) return@TrainlogChoice
distance = distance =
!distance !distance
@ -240,19 +282,37 @@ fun ExerciseScreen(
color = colors.accent, 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( TrainlogAction(
label = label =
if (inline) { if (editedExercise != null) {
"Enregistrer les modifications"
} else if (inline) {
"Créer et revenir à la séance" "Créer et revenir à la séance"
} else { } else {
"Enregistrer l'exercice" "Enregistrer l'exercice"
}, },
description = 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 = accent =
colors.success, colors.success,
onClick = { onClick = {
when ( val current = editedExercise
val result =
if (current == null) {
repository.createExercise( repository.createExercise(
NewExerciseProfile( NewExerciseProfile(
name = name, name = name,
@ -262,9 +322,20 @@ fun ExerciseScreen(
trackingMode, trackingMode,
dataFields = dataFields =
fields, fields,
),
) )
} else {
repository.editExercise(
ExerciseEditInput(
exerciseId = current.exerciseId,
name = name,
recordingMode = recordingMode,
trackingMode = trackingMode,
dataFields = fields,
),
) )
) { }
when (result) {
is CreateExerciseResult.Created -> { is CreateExerciseResult.Created -> {
message = null message = null
onSaved() onSaved()
@ -279,10 +350,50 @@ fun ExerciseScreen(
message = message =
"Profil ou nom invalide." "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) { if (message != null) {
TrainlogInfo( TrainlogInfo(
text = 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( TrainlogFrame(
title = "CONTRAT", title = "CONTRAT",
active = false, active = false,
@ -355,6 +482,7 @@ private fun TrainlogChoiceGroup(
private fun TrainlogChoice( private fun TrainlogChoice(
label: String, label: String,
selected: Boolean, selected: Boolean,
enabled: Boolean = true,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
val colors = val colors =
@ -372,7 +500,10 @@ private fun TrainlogChoice(
colors.surface colors.surface
} }
) )
.clickable(onClick = onClick) .clickable(
enabled = enabled,
onClick = onClick,
)
.padding( .padding(
horizontal = 10.dp, horizontal = 10.dp,
vertical = 9.dp, vertical = 9.dp,
@ -390,6 +521,8 @@ private fun TrainlogChoice(
color = color =
if (selected) { if (selected) {
colors.warning colors.warning
} else if (!enabled) {
colors.muted
} else { } else {
colors.text colors.text
}, },

View file

@ -1,18 +1,102 @@
package com.labfytools.trainlog.ui package com.labfytools.trainlog.ui
import androidx.compose.runtime.Composable 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 @Composable
fun HomeScreen( fun HomeScreen(
activeDraft: ActiveSessionDraft?,
draftError: String?,
onSession: () -> Unit, onSession: () -> Unit,
onDiscardDraft: () -> Unit,
onExercise: () -> Unit, onExercise: () -> Unit,
onBody: () -> Unit, onBody: () -> Unit,
onHistory: () -> Unit, onHistory: () -> Unit,
onSync: () -> Unit, onSync: () -> Unit,
) { ) {
val colors = LocalTrainlogColors.current
var confirmingDiscard by
remember(activeDraft != null) {
mutableStateOf(false)
}
TrainlogScreen( TrainlogScreen(
subtitle = "A C C U E I L" 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( TrainlogFrame(
title = "ENREGISTREMENT" title = "ENREGISTREMENT"
) { ) {
@ -20,7 +104,11 @@ fun HomeScreen(
label = label =
"Enregistrer une séance", "Enregistrer une séance",
description = 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, onClick = onSession,
) )

View file

@ -15,19 +15,21 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp 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.data.TrainlogRepository
import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.ExerciseDataFields import com.labfytools.trainlog.model.ExerciseDataFields
import com.labfytools.trainlog.model.ExerciseProfile import com.labfytools.trainlog.model.ExerciseProfile
import com.labfytools.trainlog.model.RecordingMode 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.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionSetDraft import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.SessionType import com.labfytools.trainlog.model.SessionType
@ -53,41 +55,64 @@ fun SessionScreen(
repository.listExercises() repository.listExercises()
} }
var selectedExercise by val initialLoad =
remember( remember(catalogRevision) {
catalogRevision repository.loadActiveSessionDraft()
) {
mutableStateOf<
ExerciseProfile?
>(null)
} }
var draftExercises by var activeDraft by
remember { remember(catalogRevision) {
mutableStateOf( mutableStateOf(
emptyList< (initialLoad as?
SessionExerciseDraft ActiveDraftLoadResult.Loaded)
>() ?.draft
) )
} }
var sessionType by
remember {
mutableStateOf(
SessionType.TRAINING
)
}
var sessionRevision by
remember {
mutableIntStateOf(0)
}
var message by var message by
remember { remember(catalogRevision) {
mutableStateOf< mutableStateOf<
String? 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( TrainlogScreen(
@ -96,18 +121,52 @@ fun SessionScreen(
TrainlogAction( TrainlogAction(
label = "< Retour", label = "< Retour",
description = description =
"Revenir à l'accueil.", "Revenir à l'accueil sans supprimer la séance en cours.",
/* CONTRACT: ordinary navigation never owns draft deletion. */
onClick = onBack, onClick = onBack,
accent = colors.muted, 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( TrainlogFrame(
title = "TYPE DE SEANCE" title = "TYPE DE SEANCE"
) { ) {
TrainlogAction( TrainlogAction(
label = label =
if ( if (
sessionType == currentDraft.sessionType ==
SessionType.TRAINING SessionType.TRAINING
) { ) {
"[✓] Entraînement" "[✓] Entraînement"
@ -118,7 +177,7 @@ fun SessionScreen(
"Séance normale de travail.", "Séance normale de travail.",
accent = accent =
if ( if (
sessionType == currentDraft.sessionType ==
SessionType.TRAINING SessionType.TRAINING
) { ) {
colors.success colors.success
@ -126,15 +185,20 @@ fun SessionScreen(
colors.muted colors.muted
}, },
onClick = { onClick = {
persistDraft(
currentDraft.copy(
sessionType = sessionType =
SessionType.TRAINING SessionType.TRAINING
),
null,
)
}, },
) )
TrainlogAction( TrainlogAction(
label = label =
if ( if (
sessionType == currentDraft.sessionType ==
SessionType.MAX_TEST SessionType.MAX_TEST
) { ) {
"[✓] Test max" "[✓] Test max"
@ -145,7 +209,7 @@ fun SessionScreen(
"Séance explicitement dédiée à une mesure de max.", "Séance explicitement dédiée à une mesure de max.",
accent = accent =
if ( if (
sessionType == currentDraft.sessionType ==
SessionType.MAX_TEST SessionType.MAX_TEST
) { ) {
colors.warning colors.warning
@ -153,8 +217,13 @@ fun SessionScreen(
colors.muted colors.muted
}, },
onClick = { onClick = {
persistDraft(
currentDraft.copy(
sessionType = sessionType =
SessionType.MAX_TEST SessionType.MAX_TEST
),
null,
)
}, },
) )
} }
@ -166,7 +235,7 @@ fun SessionScreen(
text = text =
"Type : " + "Type : " +
if ( if (
sessionType == currentDraft.sessionType ==
SessionType.MAX_TEST SessionType.MAX_TEST
) { ) {
"TEST MAX" "TEST MAX"
@ -175,7 +244,7 @@ fun SessionScreen(
}, },
color = color =
if ( if (
sessionType == currentDraft.sessionType ==
SessionType.MAX_TEST SessionType.MAX_TEST
) { ) {
colors.warning colors.warning
@ -185,13 +254,13 @@ fun SessionScreen(
) )
if ( if (
draftExercises.isEmpty() currentDraft.exercises.isEmpty()
) { ) {
TrainlogInfo( TrainlogInfo(
"Aucun exercice ajouté." "Aucun exercice ajouté."
) )
} else { } else {
draftExercises currentDraft.exercises
.forEachIndexed { .forEachIndexed {
index, index,
draft -> draft ->
@ -214,19 +283,18 @@ fun SessionScreen(
accent = accent =
colors.error, colors.error,
onClick = { onClick = {
draftExercises = persistDraft(
draftExercises currentDraft.copy(
exercises =
currentDraft.exercises
.filterIndexed { .filterIndexed {
itemIndex, itemIndex,
_ -> _ ->
itemIndex != itemIndex != index
index
} }
),
sessionRevision += 1 "Exercice retiré de la séance.",
)
message =
"Exercice retiré de la séance."
}, },
) )
} }
@ -249,7 +317,7 @@ fun SessionScreen(
exercise -> exercise ->
val alreadyAdded = val alreadyAdded =
draftExercises.any { currentDraft.exercises.any {
it.exercise.exerciseId == it.exercise.exerciseId ==
exercise.exerciseId exercise.exerciseId
} }
@ -258,7 +326,8 @@ fun SessionScreen(
exercise = exercise =
exercise, exercise,
selected = selected =
selectedExercise currentDraft.form
.selectedExercise
?.exerciseId == ?.exerciseId ==
exercise.exerciseId, exercise.exerciseId,
disabled = disabled =
@ -267,10 +336,16 @@ fun SessionScreen(
if ( if (
!alreadyAdded !alreadyAdded
) { ) {
persistDraft(
currentDraft.copy(
form =
currentDraft.form.copy(
selectedExercise = selectedExercise =
exercise exercise
)
message = null ),
null,
)
} }
}, },
) )
@ -278,32 +353,47 @@ fun SessionScreen(
} }
} }
if ( val editingExercise =
selectedExercise != null currentDraft.form.selectedExercise
) {
if (editingExercise != null) {
SessionExerciseForm( SessionExerciseForm(
key = key =
selectedExercise!! editingExercise.exerciseId,
.exerciseId,
exercise = exercise =
selectedExercise!!, editingExercise,
initialForm =
currentDraft.form,
onFormChanged = {
form ->
persistDraft(
currentDraft.copy(
form = form
),
null,
)
},
onCancel = { onCancel = {
selectedExercise = persistDraft(
null currentDraft.copy(
form =
SessionDraftForm()
),
null,
)
}, },
onAdd = { onAdd = {
draft -> draft ->
draftExercises = persistDraft(
draftExercises + currentDraft.copy(
draft exercises =
currentDraft.exercises +
selectedExercise = draft,
null form =
SessionDraftForm(),
sessionRevision += 1 ),
"Exercice ajouté à la séance.",
message = )
"Exercice ajouté à la séance."
}, },
) )
} }
@ -326,59 +416,87 @@ fun SessionScreen(
TrainlogFrame( TrainlogFrame(
title = "ENREGISTREMENT", title = "ENREGISTREMENT",
active = active =
draftExercises.isNotEmpty(), currentDraft.exercises.isNotEmpty(),
) { ) {
TrainlogAction( TrainlogAction(
label = label =
"Enregistrer la séance", "Enregistrer la séance",
description = description =
"${draftExercises.size} exercice(s) dans la séance.", "${currentDraft.exercises.size} exercice(s) dans la séance.",
accent = accent =
colors.success, colors.success,
onClick = { onClick = {
when ( when (
val result = val result =
repository repository.finalizeActiveSessionDraft()
.saveSession(
SessionDraft(
exercises =
draftExercises,
sessionType =
sessionType,
)
)
) { ) {
is SaveSessionResult.Saved -> { is FinalizeActiveDraftResult.Saved -> {
draftExercises =
emptyList()
selectedExercise =
null
sessionType =
SessionType.TRAINING
sessionRevision += 1
message =
"Séance enregistrée."
onSessionSaved() onSessionSaved()
onBack()
} }
SaveSessionResult.Invalid -> { is FinalizeActiveDraftResult.Invalid -> {
message = message = result.message
"Séance invalide."
} }
SaveSessionResult.DatabaseError -> { is FinalizeActiveDraftResult.DatabaseError -> {
message = 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 ( if (
message != null message != null
) { ) {
@ -387,8 +505,6 @@ fun SessionScreen(
message.orEmpty(), message.orEmpty(),
color = color =
if ( if (
message ==
"Séance enregistrée." ||
message == message ==
"Exercice ajouté à la séance." || "Exercice ajouté à la séance." ||
message == message ==
@ -479,6 +595,8 @@ private fun CatalogChoice(
private fun SessionExerciseForm( private fun SessionExerciseForm(
key: String, key: String,
exercise: ExerciseProfile, exercise: ExerciseProfile,
initialForm: SessionDraftForm,
onFormChanged: (SessionDraftForm) -> Unit,
onCancel: () -> Unit, onCancel: () -> Unit,
onAdd: onAdd:
(SessionExerciseDraft) -> (SessionExerciseDraft) ->
@ -489,27 +607,37 @@ private fun SessionExerciseForm(
var setCountText by var setCountText by
remember(key) { remember(key) {
mutableStateOf("3") mutableStateOf(
initialForm.setCountText
)
} }
var repsText by var repsText by
remember(key) { remember(key) {
mutableStateOf("3x10") mutableStateOf(
initialForm.repsText
)
} }
var durationText by var durationText by
remember(key) { remember(key) {
mutableStateOf("30") mutableStateOf(
initialForm.durationText
)
} }
var speedText by var speedText by
remember(key) { remember(key) {
mutableStateOf("") mutableStateOf(
initialForm.speedText
)
} }
var distanceText by var distanceText by
remember(key) { remember(key) {
mutableStateOf("") mutableStateOf(
initialForm.distanceText
)
} }
var error by var error by
@ -547,6 +675,16 @@ private fun SessionExerciseForm(
onValueChange = { onValueChange = {
repsText = it repsText = it
error = null error = null
onFormChanged(
currentForm(
exercise,
setCountText,
it,
durationText,
speedText,
distanceText,
)
)
}, },
) )
@ -565,6 +703,16 @@ private fun SessionExerciseForm(
onValueChange = { onValueChange = {
setCountText = it setCountText = it
error = null error = null
onFormChanged(
currentForm(
exercise,
it,
repsText,
durationText,
speedText,
distanceText,
)
)
}, },
) )
@ -576,6 +724,16 @@ private fun SessionExerciseForm(
onValueChange = { onValueChange = {
durationText = it durationText = it
error = null error = null
onFormChanged(
currentForm(
exercise,
setCountText,
repsText,
it,
speedText,
distanceText,
)
)
}, },
) )
} }
@ -588,6 +746,16 @@ private fun SessionExerciseForm(
onValueChange = { onValueChange = {
durationText = it durationText = it
error = null error = null
onFormChanged(
currentForm(
exercise,
setCountText,
repsText,
it,
speedText,
distanceText,
)
)
}, },
) )
@ -604,6 +772,16 @@ private fun SessionExerciseForm(
onValueChange = { onValueChange = {
speedText = it speedText = it
error = null error = null
onFormChanged(
currentForm(
exercise,
setCountText,
repsText,
durationText,
it,
distanceText,
)
)
}, },
) )
} }
@ -621,6 +799,16 @@ private fun SessionExerciseForm(
onValueChange = { onValueChange = {
distanceText = it distanceText = it
error = null 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 @Composable
private fun SessionNumberField( private fun SessionNumberField(
label: String, label: String,

View file

@ -10,6 +10,8 @@ import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue 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.TrainlogRepository
import com.labfytools.trainlog.data.SyncExporter import com.labfytools.trainlog.data.SyncExporter
import com.labfytools.trainlog.data.CatalogInboxResult import com.labfytools.trainlog.data.CatalogInboxResult
@ -52,6 +54,16 @@ fun TrainlogApp(
mutableIntStateOf(0) mutableIntStateOf(0)
} }
var draftRevision by
remember {
mutableIntStateOf(0)
}
var draftMessage by
remember {
mutableStateOf<String?>(null)
}
var selectedSessionId by var selectedSessionId by
remember { remember {
mutableStateOf<String?>( mutableStateOf<String?>(
@ -99,11 +111,74 @@ fun TrainlogApp(
} }
when (screen) { when (screen) {
TrainlogScreenId.HOME -> TrainlogScreenId.HOME -> {
val draftLoad =
remember(
draftRevision,
catalogRevision,
) {
repository.loadActiveSessionDraft()
}
HomeScreen( HomeScreen(
activeDraft =
(draftLoad as?
ActiveDraftLoadResult.Loaded)
?.draft,
draftError =
draftMessage
?: (draftLoad as?
ActiveDraftLoadResult.Error)
?.message
?: (draftLoad as?
ActiveDraftLoadResult.Loaded)
?.warning,
onSession = { onSession = {
when (draftLoad) {
is ActiveDraftLoadResult.Loaded -> {
draftMessage = null
screen = TrainlogScreenId.SESSION
}
ActiveDraftLoadResult.None -> {
when (
val result =
repository
.startActiveSessionDraft()
) {
ActiveDraftMutationResult.Saved -> {
draftMessage = null
draftRevision += 1
screen = screen =
TrainlogScreenId.SESSION 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 = { onExercise = {
exerciseReturnTarget = exerciseReturnTarget =
@ -125,6 +200,7 @@ fun TrainlogApp(
TrainlogScreenId.SYNC TrainlogScreenId.SYNC
}, },
) )
}
TrainlogScreenId.SESSION -> TrainlogScreenId.SESSION ->
SessionScreen( SessionScreen(
@ -132,6 +208,9 @@ fun TrainlogApp(
catalogRevision = catalogRevision =
catalogRevision, catalogRevision,
onBack = { onBack = {
/* WHY: Back changes routing only; the repository remains
* the canonical owner of the in-progress workout. */
draftRevision += 1
screen = screen =
TrainlogScreenId.HOME TrainlogScreenId.HOME
}, },
@ -144,6 +223,7 @@ fun TrainlogApp(
}, },
onSessionSaved = { onSessionSaved = {
exporter.exportMobileBundle() exporter.exportMobileBundle()
draftRevision += 1
}, },
) )

View file

@ -3,7 +3,6 @@ package com.labfytools.trainlog.ui
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.IntrinsicSize
@ -27,7 +26,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color 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.LocalTrainlogColors
import com.labfytools.trainlog.ui.theme.TrainlogTypography 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 @Composable
fun TrainlogScreen( fun TrainlogScreen(
subtitle: String, subtitle: String,
@ -87,55 +76,35 @@ private fun TrainlogBanner(
val colors = val colors =
LocalTrainlogColors.current LocalTrainlogColors.current
BoxWithConstraints( Column(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(bottom = 18.dp) .padding(bottom = 18.dp)
) { ) {
val wide = /* WHY: TUI and Android share this compact plaque rather than separate
maxWidth >= 560.dp * brand treatments. The terminal box becomes flat spacing on touch. */
Column(
modifier =
Modifier.fillMaxWidth(),
horizontalAlignment =
Alignment.Start,
) {
BasicText( BasicText(
text = text = "◆ TRAINLOG ◆",
if (wide) {
FullAsciiBanner
} else {
"T R A I N L O G"
},
style = style =
TrainlogTypography.banner.copy( TrainlogTypography.banner.copy(
color = colors.accent, color = colors.accent,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontSize = fontSize = 21.sp,
if (wide) {
14.sp
} else {
21.sp
},
), ),
) )
BasicText( BasicText(
text = subtitle, text = subtitle,
modifier = modifier = Modifier.padding(top = 5.dp),
Modifier.padding(top = 5.dp),
style = style =
TrainlogTypography.small.copy( TrainlogTypography.small.copy(
color = colors.muted, color = colors.muted,
fontWeight = fontWeight = FontWeight.Bold,
FontWeight.Bold,
), ),
) )
} }
} }
}
@Composable @Composable
fun TrainlogFrame( fun TrainlogFrame(

View file

@ -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;")
}
}
}

View file

@ -12,6 +12,7 @@ The desktop remains the canonical long-term history and analytics store.
```text ```text
Accueil Accueil
├── Reprendre la séance en cours (si un brouillon existe)
├── Enregistrer une séance ├── Enregistrer une séance
├── Enregistrer un exercice ├── Enregistrer un exercice
├── Enregistrer des mensurations ├── Enregistrer des mensurations
@ -24,7 +25,7 @@ Accueil
Android local database version: Android local database version:
```text ```text
3 4
``` ```
Domain tables cover: Domain tables cover:
@ -40,6 +41,11 @@ body_observations
This database is Android-local. It is not copied to the PC. 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 ## 4. Exercise catalog
Exercise creation records: Exercise creation records:
@ -62,6 +68,25 @@ collisions.
An exercise may be created standalone or inline while building a session. 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 ## 5. Session recording
Stable session identity: Stable session identity:
@ -97,10 +122,34 @@ Continuous work does not create fake sets.
## 6. Session draft editing ## 6. Session draft editing
Before a session is saved, an exercise already added to the draft can be The repository durably saves every meaningful mutation, including session type,
removed. 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 <exercice>** 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 ## 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. 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 ## 10. PC catalog access
PC-created files are accessed through a persistent Storage Access Framework 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. `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 ## 14. Non-goals
Android is not intended to own: Android is not intended to own:

View file

@ -37,6 +37,7 @@ Android local SQLite is a capture store, not a synchronization format.
Responsibilities: Responsibilities:
- exercise catalog entry; - exercise catalog entry;
- stable-ID exercise rename/editing;
- workout-session recording; - workout-session recording;
- performed set entry; - performed set entry;
- continuous-activity entry; - continuous-activity entry;
@ -64,7 +65,9 @@ The C17 core owns:
### TUI ### 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: It consumes core services for:
@ -132,13 +135,37 @@ body_observations
### Android ### 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 It mirrors domain concepts needed for capture, but its schema version is not
coupled to the desktop schema. coupled to the desktop schema.
Synchronization exchanges domain artifacts rather than database files. 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 ## 5. Compatibility boundaries
### Frozen Trainlog JSON v1 ### Frozen Trainlog JSON v1

View file

@ -1,6 +1,6 @@
# Current implementation state # 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 This document is the compact source of truth for the implemented Trainlog
baseline. Detailed behavior belongs in the topic-specific documents. baseline. Detailed behavior belongs in the topic-specific documents.
@ -15,7 +15,23 @@ GATE_2_PERSISTENCE_AND_USABLE_TUI=PASS
TRAINLOG_FORMAT_V1=FROZEN TRAINLOG_FORMAT_V1=FROZEN
DESKTOP_SCHEMA_V5=PASS 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 PROFILE_AWARE_EXERCISES=PASS
CONTINUOUS_ACTIVITY=PASS CONTINUOUS_ACTIVITY=PASS
@ -31,7 +47,7 @@ ANDROID_SYNC_RECEIPT=PASS
TUI_SYNC_LOG_SHOW=PASS TUI_SYNC_LOG_SHOW=PASS
BIDIRECTIONAL_SYNC_V1=PASS BIDIRECTIONAL_SYNC_V1=PASS
DESKTOP_TESTS=21/21 PASS DESKTOP_TESTS=22/22 PASS
ANDROID_BUILD=PASS ANDROID_BUILD=PASS
HARDWARE_SYNC_VALIDATION=PASS HARDWARE_SYNC_VALIDATION=PASS
``` ```
@ -40,7 +56,7 @@ HARDWARE_SYNC_VALIDATION=PASS
Implemented: Implemented:
- C17/ncursesw TUI; - C17/Notcurses true-color TUI (72x20 minimum, UTF-8 prompts, resize fallback);
- SQLite schema v5; - SQLite schema v5;
- direct session entry; - direct session entry;
- persisted session detail and editing; - persisted session detail and editing;
@ -71,8 +87,11 @@ Primary navigation:
Implemented: Implemented:
- native Kotlin/Compose application; - 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; - exercise creation;
- stable-ID exercise rename/editing with referenced-profile protection;
- inline exercise creation during session entry; - inline exercise creation during session entry;
- profile-aware session recording; - profile-aware session recording;
- heterogeneous repetition-set entry; - heterogeneous repetition-set entry;
@ -85,6 +104,17 @@ Implemented:
- Android-triggered synchronization request; - Android-triggered synchronization request;
- synchronization receipt handling. - 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 ## Synchronization
Canonical exchange directory: Canonical exchange directory:
@ -120,7 +150,7 @@ No mounted Android filesystem is required.
Desktop: Desktop:
```text ```text
21/21 Meson tests PASS 22/22 Meson tests PASS
frozen JSON validator PASS frozen JSON validator PASS
import-contract validator PASS import-contract validator PASS
git diff --check PASS git diff --check PASS
@ -130,13 +160,18 @@ Android:
```text ```text
assembleDebug PASS 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 real Samsung request -> daemon -> bidirectional sync -> receipt PASS
multiple distinct request IDs consumed once each PASS multiple distinct request IDs consumed once each PASS
``` ```
## Current implementation cursor ## 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 ```text
MEASURED_MAX_V1=PASS MEASURED_MAX_V1=PASS
@ -156,7 +191,7 @@ MEASURED_MAX_ONLY_FROM_MAX_TEST=PASS
WORKING_LOAD_PERCENTAGES=PASS WORKING_LOAD_PERCENTAGES=PASS
ASSISTANCE_DIRECTION_AWARE=PASS ASSISTANCE_DIRECTION_AWARE=PASS
ANDROID_MAX_TEST_SESSION=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 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_PROPORTION_RATIOS=PASS
BODY_SYMMETRY_ANALYTICS=PASS BODY_SYMMETRY_ANALYTICS=PASS
NO_ESTIMATE_PERSISTENCE=PASS NO_ESTIMATE_PERSISTENCE=PASS
DESKTOP_TESTS=21/21 PASS DESKTOP_TESTS=22/22 PASS
``` ```
Android remains capture-only for this feature. Android remains capture-only for this feature.

View file

@ -258,6 +258,27 @@ distance km
The Android SQLite database is independent. 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. Desktop and Android schema versions are not required to match.
Do not synchronize SQLite database files. Do not synchronize SQLite database files.
@ -275,7 +296,7 @@ Migration-specific regression coverage includes:
schema_v5_migration schema_v5_migration
``` ```
The current normal suite contains 19 tests. The current normal desktop suite contains 22 tests.
## 11. Measured-max derivation ## 11. Measured-max derivation

View file

@ -524,9 +524,11 @@ For each incoming exercise, the TUI reconciles against the local canonical catal
Reuse the existing exercise. Reuse the existing exercise.
If the normalized display name differs, import is allowed but a non-fatal metadata warning is surfaced. If the normalized display name differs, update `name` and `normalized_name` in
the existing catalog row. This is a stable-ID rename: completed session
The canonical local name is not silently changed. 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 ### Same ID, different tracking mode

View file

@ -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.

View file

@ -15,7 +15,7 @@ GATE_2=PASS
TRAINLOG_FORMAT_V1=FROZEN TRAINLOG_FORMAT_V1=FROZEN
DESKTOP_SCHEMA_V5=PASS DESKTOP_SCHEMA_V5=PASS
ANDROID_LOCAL_DATABASE_V3=PASS ANDROID_LOCAL_DATABASE_V4=PASS
DIRECT_MTP_TRANSPORT=PASS DIRECT_MTP_TRANSPORT=PASS
BIDIRECTIONAL_SYNC_V1=PASS BIDIRECTIONAL_SYNC_V1=PASS
@ -23,14 +23,19 @@ BIDIRECTIONAL_SYNC_V1=PASS
VARIABLE_REPETITION_SETS=PASS VARIABLE_REPETITION_SETS=PASS
MEASURED_MAX_V1=PASS MEASURED_MAX_V1=PASS
BODY_ANALYTICS_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: The current product baseline includes:
- SQLite persistence and explicit migrations; - SQLite persistence and explicit migrations;
- usable ncurses desktop TUI; - usable Notcurses desktop TUI;
- native Android capture client; - native Android capture client;
- exercise catalog; - exercise catalog;
- profile-aware set and continuous activity; - profile-aware set and continuous activity;
@ -43,8 +48,19 @@ The current product baseline includes:
- bidirectional Android/PC synchronization; - bidirectional Android/PC synchronization;
- shared synchronization engine and `trainlog-syncd`. - 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 ## 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: The intended split remains:
```text ```text

View file

@ -103,6 +103,13 @@ continuous
No synthetic set is created for continuous work. 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 ## 5. Desktop mobile importer
Reference importer: Reference importer:

View file

@ -79,15 +79,23 @@ Current normal suite:
16 usb 16 usb
17 variable_sets 17 variable_sets
18 schema_v5_migration 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: Validated checkpoint:
```text ```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: Notable regression coverage:
- transactional persisted-session replacement; - transactional persisted-session replacement;
@ -98,8 +106,12 @@ Notable regression coverage:
- repetition shorthand/list/pyramid parsing; - repetition shorthand/list/pyramid parsing;
- direct v4 -> v5 database migration; - direct v4 -> v5 database migration;
- heterogeneous mobile-set import; - 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; - targetless mobile SETS persistence;
- mobile-import idempotence. - mobile-import idempotence.
- stable-ID mobile-to-desktop rename reconciliation without duplicate catalog
rows or historical-reference replacement.
## 5. Build ## 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 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 ## 7. Hardware MTP validation
Hardware probes and real synchronization are separate from the normal automated Hardware probes and real synchronization are separate from the normal automated
@ -238,7 +258,7 @@ Coverage proves:
Current normal baseline: Current normal baseline:
```text ```text
21/21 PASS 22/22 PASS
``` ```
## 12. Body analytics regression ## 12. Body analytics regression
@ -263,5 +283,53 @@ Coverage includes:
Current normal baseline: Current normal baseline:
```text ```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).

View file

@ -2,7 +2,7 @@
## 1. Purpose ## 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 history, correction, analysis, visualization, direct data entry, and manual
synchronization. synchronization.
@ -46,6 +46,22 @@ Smaller terminals display a clear fallback instead of corrupt layout.
The TUI uses centralized semantic theme roles. 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. Color is not the sole state carrier.
Typical roles: Typical roles:

View file

@ -265,6 +265,19 @@ def main() -> int:
+ second + 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( connection = sqlite3.connect(
database_path database_path
) )
@ -317,6 +330,23 @@ def main() -> int:
raise AssertionError( raise AssertionError(
f"fake target persisted: {target!r}" 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: finally:
connection.close() connection.close()

View file

@ -736,11 +736,45 @@ def import_exercises(
f"profil incompatible pour {exercise_id}" 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] = ( mapping[exercise_id] = (
by_id["exercise_id"] by_id["exercise_id"]
) )
report["exercises_skipped"] += 1
continue continue
by_name = lookup_exercise_by_normalized( by_name = lookup_exercise_by_normalized(

View file

@ -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 <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#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

View file

@ -1,11 +1,11 @@
#ifndef TRAINLOG_THEME_H #ifndef TRAINLOG_THEME_H
#define TRAINLOG_THEME_H #define TRAINLOG_THEME_H
#include <curses.h> #include <stdint.h>
/** /**
* @file theme.h * @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 { typedef enum TrainlogColorRole {
@ -18,7 +18,16 @@ typedef enum TrainlogColorRole {
TRAINLOG_COLOR_GRAPH = 6 TRAINLOG_COLOR_GRAPH = 6
} TrainlogColorRole; } 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 #endif

View file

@ -3,7 +3,7 @@
/** /**
* @file tui.h * @file tui.h
* @brief Interactive ncurses entry point. * @brief Interactive Notcurses entry point.
*/ */
#include "trainlog/database.h" #include "trainlog/database.h"

View file

@ -5,10 +5,7 @@ uuid_dep = dependency('uuid', required: true)
udev_dep = dependency('libudev', required: true) udev_dep = dependency('libudev', required: true)
mtp_dep = dependency('libmtp', required: true) mtp_dep = dependency('libmtp', required: true)
ncursesw_dep = dependency('ncursesw', required: false) notcurses_dep = dependency('notcurses-core', required: true)
if not ncursesw_dep.found()
ncursesw_dep = cc.find_library('ncursesw', required: true)
endif
utf8proc_dep = dependency('libutf8proc', required: false) utf8proc_dep = dependency('libutf8proc', required: false)
if not utf8proc_dep.found() if not utf8proc_dep.found()
@ -71,6 +68,7 @@ trainlog_core_dep = declare_dependency(
trainlog_tui_sources = files( trainlog_tui_sources = files(
'src/main.c', 'src/main.c',
'src/terminal.c',
'src/theme.c', 'src/theme.c',
'src/tui.c', 'src/tui.c',
) )
@ -80,7 +78,7 @@ trainlog_exe = executable(
trainlog_tui_sources, trainlog_tui_sources,
dependencies: [ dependencies: [
trainlog_core_dep, trainlog_core_dep,
ncursesw_dep, notcurses_dep,
], ],
c_args: strict_c_args, c_args: strict_c_args,
install: true, install: true,
@ -367,3 +365,17 @@ test(
'body_analytics', 'body_analytics',
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,
)

419
tui/src/terminal.c Normal file
View file

@ -0,0 +1,419 @@
/**
* @file terminal.c
* @brief Explicit, bounded Notcurses adapter used only by the desktop TUI.
*/
#include "trainlog/terminal.h"
#include <stdarg.h>
#include <limits.h>
#include <stdio.h>
#include <stdlib.h>
#include <notcurses/notcurses.h>
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; }

View file

@ -1,34 +1,11 @@
/** /**
* @file theme.c * @file theme.c
* @brief Centralized Trainlog ncurses colors. * @brief Centralized Trainlog semantic text styles.
*/ */
#include "trainlog/theme.h" #include "trainlog/theme.h"
#include <curses.h> TrainlogTextStyle trainlog_theme_style(TrainlogColorRole role)
void trainlog_theme_initialize(void)
{ {
if (!has_colors()) { return ((TrainlogTextStyle)role << 8U);
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);
} }

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,90 @@
/**
* @file test_terminal_input.c
* @brief Deterministic tests for the Notcurses input lifecycle boundary.
*/
#include <stdbool.h>
#include <stdio.h>
#include <notcurses/notcurses.h>
#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;
}