Modernize TUI and harden Android workout capture
This commit is contained in:
parent
444a4d2d8d
commit
c9c4d3b9ce
37 changed files with 5024 additions and 1414 deletions
27
AGENTS.md
27
AGENTS.md
|
|
@ -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
|
||||||
|
|
|
||||||
28
CHANGELOG.md
28
CHANGELOG.md
|
|
@ -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
|
||||||
|
|
|
||||||
28
README.md
28
README.md
|
|
@ -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
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
8
android/app/src/debug/AndroidManifest.xml
Normal file
8
android/app/src/debug/AndroidManifest.xml
Normal 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>
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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;")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
179
docs/reviews/android_session_draft_v1_resume.md
Normal file
179
docs/reviews/android_session_draft_v1_resume.md
Normal 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.
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
18
docs/tui.md
18
docs/tui.md
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
93
tui/include/trainlog/terminal.h
Normal file
93
tui/include/trainlog/terminal.h
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
419
tui/src/terminal.c
Normal 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; }
|
||||||
|
|
@ -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);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
2129
tui/src/tui.c
2129
tui/src/tui.c
File diff suppressed because it is too large
Load diff
90
tui/tests/test_terminal_input.c
Normal file
90
tui/tests/test_terminal_input.c
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue