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.
|
||||
|
||||
Android exercise editing preserves `exercise_id`: a rename trims and
|
||||
re-normalizes display metadata in the existing row and must not create a second
|
||||
exercise. Profile changes are rejected once completed history or an active draft
|
||||
references the exercise; renaming remains safe. Same-ID catalog reconciliation
|
||||
updates name metadata in place and rejects different-ID normalized-name
|
||||
collisions.
|
||||
|
||||
## 5. Desktop implementation
|
||||
|
||||
The desktop core is C17.
|
||||
|
|
@ -82,17 +89,15 @@ Current primary dependencies:
|
|||
- Ninja;
|
||||
- one active desktop terminal backend.
|
||||
|
||||
For the authorized `TUI_NOTCURSES_V1` tranche:
|
||||
For the completed `TUI_NOTCURSES_V1` infrastructure checkpoint:
|
||||
|
||||
```text
|
||||
legacy backend = ncursesw
|
||||
target backend = Notcurses
|
||||
legacy backend = ncursesw (historical only)
|
||||
active backend = Notcurses
|
||||
```
|
||||
|
||||
During the migration, ncursesw may remain only as the pre-migration
|
||||
implementation being replaced. Once `TUI_NOTCURSES_V1=PASS`, active desktop TUI
|
||||
code and build wiring must use Notcurses and must not retain ncursesw as an
|
||||
unused permanent compatibility backend.
|
||||
`TUI_NOTCURSES_V1=PASS`. Active desktop TUI code and build wiring use
|
||||
Notcurses and do not retain ncursesw as an unused compatibility backend.
|
||||
|
||||
Business logic, persistence, transport, and rendering remain separated.
|
||||
|
||||
|
|
@ -159,6 +164,14 @@ It is not the canonical analytics store.
|
|||
The Android UI is driven by exercise metadata, never by exercise-name
|
||||
heuristics.
|
||||
|
||||
Android local SQLite schema v4 owns exactly one durable active-session draft.
|
||||
Every meaningful draft/form mutation is persisted by the repository. Back,
|
||||
backgrounding and process death never delete the draft. Home offers explicit
|
||||
resume; whole-draft discard requires confirmation. Final completed-session
|
||||
insertion and draft deletion are one transaction. Drafts are excluded from
|
||||
completed history and mobile export. Preserve raw partial form input and use
|
||||
an explicit, non-destructive migration for future Android schema changes.
|
||||
|
||||
## 7. Synchronization architecture
|
||||
|
||||
Desktop access to Android uses physical-device discovery with `libudev` and
|
||||
|
|
|
|||
28
CHANGELOG.md
28
CHANGELOG.md
|
|
@ -9,6 +9,15 @@ Detailed implementation chronology remains available in Git history and
|
|||
|
||||
### Added
|
||||
|
||||
- Android `EXERCISE_EDIT_V1`: visible catalog editing, stable-ID name rename,
|
||||
explicit invalid/conflict/profile/database results, and profile locking once
|
||||
completed history or an active draft references the exercise;
|
||||
- `ANDROID_BANNER_PARITY_V1`: one Android `◆ TRAINLOG ◆` header component
|
||||
matching the compact Notcurses banner's accent and muted context rhythm;
|
||||
|
||||
- one durable Android active-session draft with Home resume, raw form restore,
|
||||
confirmed discard and draft-only exercise removal;
|
||||
|
||||
- native Kotlin/Compose Android capture client;
|
||||
- Android local exercise, session, continuous-activity, and body persistence;
|
||||
- C17/ncursesw desktop TUI with direct session entry and durable SQLite history;
|
||||
|
|
@ -36,6 +45,14 @@ Detailed implementation chronology remains available in Git history and
|
|||
|
||||
### Changed
|
||||
|
||||
- same-ID Android ↔ PC catalog reconciliation now updates display-name metadata
|
||||
in place and rejects a different-ID normalized-name collision, preserving
|
||||
synchronization identity and preventing renamed duplicates;
|
||||
|
||||
- Android local SQLite v3 -> v4 additive migration for structured active drafts;
|
||||
- completed-session insertion and draft clearing are atomic; drafts remain
|
||||
excluded from completed history and frozen mobile export;
|
||||
|
||||
- desktop SQLite schema evolved to v5;
|
||||
- schema v5 permits targetless set-session rows for actual-only mobile data;
|
||||
- heterogeneous performed sets are preserved without inventing a uniform target;
|
||||
|
|
@ -51,6 +68,11 @@ Detailed implementation chronology remains available in Git history and
|
|||
|
||||
### Fixed
|
||||
|
||||
- in-progress Android workout loss when leaving the foreground or recreating
|
||||
the Activity/process;
|
||||
- missing selected-exercise recovery preserves raw partial input and reports a
|
||||
specific warning; draft write/finalization failures return explicit errors;
|
||||
|
||||
- stale schema-v4 importer call after desktop schema v5 migration;
|
||||
- stale schema-v4 guard in the PC catalog exporter;
|
||||
- missing `sy` prefix support in the UUID creator;
|
||||
|
|
@ -67,10 +89,14 @@ Current validated baseline:
|
|||
TRAINLOG_FORMAT_V1=FROZEN
|
||||
|
||||
DESKTOP_SCHEMA_V5=PASS
|
||||
DESKTOP_TESTS=19/19 PASS
|
||||
DESKTOP_TESTS=22/22 PASS
|
||||
|
||||
ANDROID_BUILD=PASS
|
||||
ANDROID_LOCAL_WORKFLOWS=PASS
|
||||
ANDROID_LOCAL_DATABASE_V4=PASS
|
||||
ANDROID_HOST_TESTS=8/8 PASS
|
||||
ANDROID_DEVICE_INSTRUMENTATION=5/5 PASS
|
||||
ANDROID_SESSION_DRAFT_V1=PASS
|
||||
|
||||
USB_MTP_DETECTION=PASS
|
||||
MTP_ROUNDTRIP=PASS
|
||||
|
|
|
|||
28
README.md
28
README.md
|
|
@ -4,7 +4,7 @@ Trainlog is a local-first workout and body-tracking system with two user
|
|||
interfaces:
|
||||
|
||||
- a native Android application optimized for fast data entry during training;
|
||||
- a C17/ncursesw TUI used for durable history, editing, visualization,
|
||||
- a C17/Notcurses TUI used for durable history, editing, visualization,
|
||||
statistics, and synchronization.
|
||||
|
||||
The desktop SQLite database is the canonical long-term history. Android keeps
|
||||
|
|
@ -18,6 +18,10 @@ TRAINLOG_FORMAT_V1=FROZEN
|
|||
|
||||
DESKTOP_SCHEMA_V5=PASS
|
||||
ANDROID_LOCAL_WORKFLOWS=PASS
|
||||
ANDROID_LOCAL_DATABASE_V4=PASS
|
||||
ANDROID_SESSION_DRAFT_V1=PASS
|
||||
EXERCISE_EDIT_V1=PASS
|
||||
ANDROID_BANNER_PARITY_V1=PASS
|
||||
|
||||
VARIABLE_REPETITION_SETS=PASS
|
||||
CONTINUOUS_ACTIVITY_TRACKING=PASS
|
||||
|
|
@ -29,7 +33,7 @@ ANDROID_TRIGGERED_SYNC=PASS
|
|||
ANDROID_SYNC_RECEIPT=PASS
|
||||
BIDIRECTIONAL_SYNC_V1=PASS
|
||||
|
||||
DESKTOP_TESTS=21/21 PASS
|
||||
DESKTOP_TESTS=22/22 PASS
|
||||
ANDROID_BUILD=PASS
|
||||
```
|
||||
|
||||
|
|
@ -100,7 +104,7 @@ Actual repetition sets are stored independently. Compact input supports:
|
|||
|
||||
```text
|
||||
android/ native Kotlin/Compose Android client
|
||||
tui/ C17 ncursesw desktop application and core
|
||||
tui/ C17 Notcurses desktop application and core
|
||||
docs/ canonical project documentation
|
||||
format/ frozen Trainlog JSON v1 schema material
|
||||
examples/ valid frozen-format examples
|
||||
|
|
@ -123,6 +127,22 @@ git diff --check
|
|||
|
||||
## Android build
|
||||
|
||||
Android keeps one durable in-progress workout in its local SQLite database.
|
||||
Home offers **Reprendre la séance en cours** after navigation, app switching,
|
||||
Activity recreation, process death or force-stop/relaunch. Added exercises and
|
||||
raw unfinished form text are retained. Removing an exercise affects only the
|
||||
draft; abandoning the draft requires confirmation. Final save atomically
|
||||
creates completed history and clears the draft. Drafts never enter mobile
|
||||
export or desktop synchronization as completed sessions.
|
||||
|
||||
Schema v4 migrates additively from v3, preserving existing capture data. See
|
||||
[Android behavior](docs/android.md) and [validation](docs/tests.md).
|
||||
|
||||
Exercises can be renamed in place from Android. The `ex_<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
|
||||
`ANDROID_HOME` or `android/local.properties`.
|
||||
|
||||
|
|
@ -226,5 +246,5 @@ BODY_ANALYTICS_V1=PASS
|
|||
BODY_COMPOSITION_ESTIMATE=PASS
|
||||
BODY_PROPORTION_RATIOS=PASS
|
||||
BODY_SYMMETRY_ANALYTICS=PASS
|
||||
DESKTOP_TESTS=21/21 PASS
|
||||
DESKTOP_TESTS=22/22 PASS
|
||||
```
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ android {
|
|||
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
testInstrumentationRunner =
|
||||
"androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
|
|
@ -24,6 +26,19 @@ android {
|
|||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests.isIncludeAndroidResources = true
|
||||
unitTests.all {
|
||||
it.systemProperty(
|
||||
"user.home",
|
||||
layout.buildDirectory
|
||||
.get()
|
||||
.asFile
|
||||
.absolutePath,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
|
@ -31,6 +46,7 @@ dependencies {
|
|||
platform("androidx.compose:compose-bom:2026.08.00")
|
||||
|
||||
implementation(composeBom)
|
||||
androidTestImplementation(composeBom)
|
||||
|
||||
implementation(
|
||||
"androidx.activity:activity-compose:1.13.0"
|
||||
|
|
@ -53,4 +69,12 @@ dependencies {
|
|||
debugImplementation(
|
||||
"androidx.compose.ui:ui-tooling"
|
||||
)
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("androidx.test:core:1.7.0")
|
||||
testImplementation("org.robolectric:robolectric:4.16.1")
|
||||
|
||||
androidTestImplementation("androidx.test.ext:junit:1.3.0")
|
||||
androidTestImplementation("androidx.test:runner:1.7.0")
|
||||
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
)
|
||||
|
||||
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(
|
||||
val sessionId: String,
|
||||
val startedAt: String,
|
||||
|
|
|
|||
|
|
@ -16,8 +16,11 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.labfytools.trainlog.data.CreateExerciseResult
|
||||
import com.labfytools.trainlog.data.EditExerciseResult
|
||||
import com.labfytools.trainlog.data.TrainlogRepository
|
||||
import com.labfytools.trainlog.model.ExerciseDataFields
|
||||
import com.labfytools.trainlog.model.ExerciseEditInput
|
||||
import com.labfytools.trainlog.model.ExerciseProfile
|
||||
import com.labfytools.trainlog.model.NewExerciseProfile
|
||||
import com.labfytools.trainlog.model.RecordingMode
|
||||
import com.labfytools.trainlog.model.TrackingMode
|
||||
|
|
@ -70,6 +73,28 @@ fun ExerciseScreen(
|
|||
)
|
||||
}
|
||||
|
||||
var editedExercise by
|
||||
remember {
|
||||
mutableStateOf<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(
|
||||
subtitle = "E X E R C I C E"
|
||||
) {
|
||||
|
|
@ -91,7 +116,12 @@ fun ExerciseScreen(
|
|||
)
|
||||
|
||||
TrainlogFrame(
|
||||
title = "NOUVEL EXERCICE"
|
||||
title =
|
||||
if (editedExercise == null) {
|
||||
"NOUVEL EXERCICE"
|
||||
} else {
|
||||
"MODIFIER L'EXERCICE"
|
||||
}
|
||||
) {
|
||||
TrainlogField(
|
||||
label = "Nom",
|
||||
|
|
@ -110,7 +140,9 @@ fun ExerciseScreen(
|
|||
selected =
|
||||
recordingMode ==
|
||||
RecordingMode.SETS,
|
||||
enabled = !profileLocked,
|
||||
onClick = {
|
||||
if (profileLocked) return@TrainlogChoice
|
||||
recordingMode =
|
||||
RecordingMode.SETS
|
||||
|
||||
|
|
@ -125,7 +157,9 @@ fun ExerciseScreen(
|
|||
selected =
|
||||
recordingMode ==
|
||||
RecordingMode.CONTINUOUS,
|
||||
enabled = !profileLocked,
|
||||
onClick = {
|
||||
if (profileLocked) return@TrainlogChoice
|
||||
recordingMode =
|
||||
RecordingMode.CONTINUOUS
|
||||
|
||||
|
|
@ -150,7 +184,9 @@ fun ExerciseScreen(
|
|||
selected =
|
||||
trackingMode ==
|
||||
TrackingMode.REPS,
|
||||
enabled = !profileLocked,
|
||||
onClick = {
|
||||
if (profileLocked) return@TrainlogChoice
|
||||
trackingMode =
|
||||
TrackingMode.REPS
|
||||
|
||||
|
|
@ -164,7 +200,9 @@ fun ExerciseScreen(
|
|||
selected =
|
||||
trackingMode ==
|
||||
TrackingMode.DURATION,
|
||||
enabled = !profileLocked,
|
||||
onClick = {
|
||||
if (profileLocked) return@TrainlogChoice
|
||||
trackingMode =
|
||||
TrackingMode.DURATION
|
||||
|
||||
|
|
@ -184,7 +222,9 @@ fun ExerciseScreen(
|
|||
TrainlogChoice(
|
||||
label = "Vitesse",
|
||||
selected = speed,
|
||||
enabled = !profileLocked,
|
||||
onClick = {
|
||||
if (profileLocked) return@TrainlogChoice
|
||||
speed = !speed
|
||||
message = null
|
||||
},
|
||||
|
|
@ -193,7 +233,9 @@ fun ExerciseScreen(
|
|||
TrainlogChoice(
|
||||
label = "Distance",
|
||||
selected = distance,
|
||||
enabled = !profileLocked,
|
||||
onClick = {
|
||||
if (profileLocked) return@TrainlogChoice
|
||||
distance =
|
||||
!distance
|
||||
|
||||
|
|
@ -240,31 +282,60 @@ fun ExerciseScreen(
|
|||
color = colors.accent,
|
||||
)
|
||||
|
||||
if (profileLocked) {
|
||||
TrainlogInfo(
|
||||
text =
|
||||
"Profil verrouillé : cet exercice est déjà référencé " +
|
||||
"par une séance terminée ou le brouillon actif. " +
|
||||
"Le nom reste modifiable.",
|
||||
color = colors.warning,
|
||||
)
|
||||
}
|
||||
|
||||
TrainlogAction(
|
||||
label =
|
||||
if (inline) {
|
||||
if (editedExercise != null) {
|
||||
"Enregistrer les modifications"
|
||||
} else if (inline) {
|
||||
"Créer et revenir à la séance"
|
||||
} else {
|
||||
"Enregistrer l'exercice"
|
||||
},
|
||||
description =
|
||||
"Ajouter ce profil au catalogue local.",
|
||||
if (editedExercise == null) {
|
||||
"Ajouter ce profil au catalogue local."
|
||||
} else {
|
||||
"Conserver l'identité et mettre à jour le catalogue."
|
||||
},
|
||||
accent =
|
||||
colors.success,
|
||||
onClick = {
|
||||
when (
|
||||
repository.createExercise(
|
||||
NewExerciseProfile(
|
||||
name = name,
|
||||
recordingMode =
|
||||
recordingMode,
|
||||
trackingMode =
|
||||
trackingMode,
|
||||
dataFields =
|
||||
fields,
|
||||
val current = editedExercise
|
||||
val result =
|
||||
if (current == null) {
|
||||
repository.createExercise(
|
||||
NewExerciseProfile(
|
||||
name = name,
|
||||
recordingMode =
|
||||
recordingMode,
|
||||
trackingMode =
|
||||
trackingMode,
|
||||
dataFields =
|
||||
fields,
|
||||
),
|
||||
)
|
||||
)
|
||||
) {
|
||||
} else {
|
||||
repository.editExercise(
|
||||
ExerciseEditInput(
|
||||
exerciseId = current.exerciseId,
|
||||
name = name,
|
||||
recordingMode = recordingMode,
|
||||
trackingMode = trackingMode,
|
||||
dataFields = fields,
|
||||
),
|
||||
)
|
||||
}
|
||||
when (result) {
|
||||
is CreateExerciseResult.Created -> {
|
||||
message = null
|
||||
onSaved()
|
||||
|
|
@ -279,10 +350,50 @@ fun ExerciseScreen(
|
|||
message =
|
||||
"Profil ou nom invalide."
|
||||
}
|
||||
|
||||
is EditExerciseResult.Saved -> {
|
||||
message = null
|
||||
editedExercise = null
|
||||
onSaved()
|
||||
}
|
||||
|
||||
EditExerciseResult.Conflict -> {
|
||||
message = "Un autre exercice porte déjà ce nom."
|
||||
}
|
||||
|
||||
EditExerciseResult.InvalidNameOrProfile -> {
|
||||
message = "Nom ou profil invalide."
|
||||
}
|
||||
|
||||
EditExerciseResult.IncompatibleProfileChange -> {
|
||||
message =
|
||||
"Le profil ne peut pas changer après utilisation."
|
||||
}
|
||||
|
||||
EditExerciseResult.DatabaseError -> {
|
||||
message = "Enregistrement en base impossible."
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if (editedExercise != null) {
|
||||
TrainlogAction(
|
||||
label = "Annuler",
|
||||
description = "Revenir au catalogue sans modification.",
|
||||
accent = colors.muted,
|
||||
onClick = {
|
||||
editedExercise = null
|
||||
name = ""
|
||||
recordingMode = RecordingMode.SETS
|
||||
trackingMode = TrackingMode.REPS
|
||||
speed = false
|
||||
distance = false
|
||||
message = null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (message != null) {
|
||||
TrainlogInfo(
|
||||
text =
|
||||
|
|
@ -292,6 +403,22 @@ fun ExerciseScreen(
|
|||
}
|
||||
}
|
||||
|
||||
TrainlogFrame(title = "EXERCICES EXISTANTS", active = false) {
|
||||
val exercises = repository.listExercises()
|
||||
if (exercises.isEmpty()) {
|
||||
TrainlogInfo("Aucun exercice enregistré.")
|
||||
} else {
|
||||
exercises.forEach { exercise ->
|
||||
TrainlogAction(
|
||||
label = "Modifier · ${exercise.name}",
|
||||
description = "Modifier le nom ou le profil si disponible.",
|
||||
accent = colors.accent,
|
||||
onClick = { startEditing(exercise) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TrainlogFrame(
|
||||
title = "CONTRAT",
|
||||
active = false,
|
||||
|
|
@ -355,6 +482,7 @@ private fun TrainlogChoiceGroup(
|
|||
private fun TrainlogChoice(
|
||||
label: String,
|
||||
selected: Boolean,
|
||||
enabled: Boolean = true,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val colors =
|
||||
|
|
@ -372,7 +500,10 @@ private fun TrainlogChoice(
|
|||
colors.surface
|
||||
}
|
||||
)
|
||||
.clickable(onClick = onClick)
|
||||
.clickable(
|
||||
enabled = enabled,
|
||||
onClick = onClick,
|
||||
)
|
||||
.padding(
|
||||
horizontal = 10.dp,
|
||||
vertical = 9.dp,
|
||||
|
|
@ -390,6 +521,8 @@ private fun TrainlogChoice(
|
|||
color =
|
||||
if (selected) {
|
||||
colors.warning
|
||||
} else if (!enabled) {
|
||||
colors.muted
|
||||
} else {
|
||||
colors.text
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,18 +1,102 @@
|
|||
package com.labfytools.trainlog.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||
import com.labfytools.trainlog.model.SessionType
|
||||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
activeDraft: ActiveSessionDraft?,
|
||||
draftError: String?,
|
||||
onSession: () -> Unit,
|
||||
onDiscardDraft: () -> Unit,
|
||||
onExercise: () -> Unit,
|
||||
onBody: () -> Unit,
|
||||
onHistory: () -> Unit,
|
||||
onSync: () -> Unit,
|
||||
) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
var confirmingDiscard by
|
||||
remember(activeDraft != null) {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
TrainlogScreen(
|
||||
subtitle = "A C C U E I L"
|
||||
) {
|
||||
if (activeDraft != null) {
|
||||
TrainlogFrame(
|
||||
title = "SÉANCE EN COURS"
|
||||
) {
|
||||
TrainlogAction(
|
||||
label =
|
||||
"Reprendre la séance en cours",
|
||||
description =
|
||||
(if (
|
||||
activeDraft.sessionType ==
|
||||
SessionType.MAX_TEST
|
||||
) {
|
||||
"Test max"
|
||||
} else {
|
||||
"Entraînement"
|
||||
}) +
|
||||
" · ${activeDraft.exercises.size} exercice(s)",
|
||||
accent = colors.success,
|
||||
onClick = onSession,
|
||||
)
|
||||
|
||||
TrainlogAction(
|
||||
label =
|
||||
"Supprimer la séance en cours",
|
||||
description =
|
||||
"Supprimer le brouillon, sans modifier l'historique.",
|
||||
accent = colors.error,
|
||||
onClick = {
|
||||
confirmingDiscard = true
|
||||
},
|
||||
)
|
||||
|
||||
if (confirmingDiscard) {
|
||||
TrainlogAction(
|
||||
label =
|
||||
"Confirmer la suppression",
|
||||
description =
|
||||
"Abandonner définitivement cette séance en cours.",
|
||||
accent = colors.error,
|
||||
onClick = {
|
||||
confirmingDiscard = false
|
||||
onDiscardDraft()
|
||||
},
|
||||
)
|
||||
TrainlogAction(
|
||||
label = "Annuler",
|
||||
description =
|
||||
"Conserver la séance en cours.",
|
||||
accent = colors.muted,
|
||||
onClick = {
|
||||
confirmingDiscard = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (draftError != null) {
|
||||
TrainlogFrame(
|
||||
title = "BROUILLON"
|
||||
) {
|
||||
TrainlogInfo(
|
||||
text = draftError,
|
||||
color = colors.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
TrainlogFrame(
|
||||
title = "ENREGISTREMENT"
|
||||
) {
|
||||
|
|
@ -20,7 +104,11 @@ fun HomeScreen(
|
|||
label =
|
||||
"Enregistrer une séance",
|
||||
description =
|
||||
"Saisir un entraînement et ses exercices.",
|
||||
if (activeDraft == null) {
|
||||
"Saisir un entraînement et ses exercices."
|
||||
} else {
|
||||
"Ouvrir la séance en cours sans l'écraser."
|
||||
},
|
||||
onClick = onSession,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -15,19 +15,21 @@ import androidx.compose.foundation.layout.padding
|
|||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.labfytools.trainlog.data.SaveSessionResult
|
||||
import com.labfytools.trainlog.data.ActiveDraftLoadResult
|
||||
import com.labfytools.trainlog.data.ActiveDraftMutationResult
|
||||
import com.labfytools.trainlog.data.FinalizeActiveDraftResult
|
||||
import com.labfytools.trainlog.data.TrainlogRepository
|
||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||
import com.labfytools.trainlog.model.ExerciseDataFields
|
||||
import com.labfytools.trainlog.model.ExerciseProfile
|
||||
import com.labfytools.trainlog.model.RecordingMode
|
||||
import com.labfytools.trainlog.model.SessionDraft
|
||||
import com.labfytools.trainlog.model.SessionDraftForm
|
||||
import com.labfytools.trainlog.model.SessionExerciseDraft
|
||||
import com.labfytools.trainlog.model.SessionSetDraft
|
||||
import com.labfytools.trainlog.model.SessionType
|
||||
|
|
@ -53,41 +55,64 @@ fun SessionScreen(
|
|||
repository.listExercises()
|
||||
}
|
||||
|
||||
var selectedExercise by
|
||||
remember(
|
||||
catalogRevision
|
||||
) {
|
||||
mutableStateOf<
|
||||
ExerciseProfile?
|
||||
>(null)
|
||||
val initialLoad =
|
||||
remember(catalogRevision) {
|
||||
repository.loadActiveSessionDraft()
|
||||
}
|
||||
|
||||
var draftExercises by
|
||||
remember {
|
||||
var activeDraft by
|
||||
remember(catalogRevision) {
|
||||
mutableStateOf(
|
||||
emptyList<
|
||||
SessionExerciseDraft
|
||||
>()
|
||||
(initialLoad as?
|
||||
ActiveDraftLoadResult.Loaded)
|
||||
?.draft
|
||||
)
|
||||
}
|
||||
|
||||
var sessionType by
|
||||
remember {
|
||||
mutableStateOf(
|
||||
SessionType.TRAINING
|
||||
)
|
||||
}
|
||||
|
||||
var sessionRevision by
|
||||
remember {
|
||||
mutableIntStateOf(0)
|
||||
}
|
||||
|
||||
var message by
|
||||
remember {
|
||||
remember(catalogRevision) {
|
||||
mutableStateOf<
|
||||
String?
|
||||
>(null)
|
||||
>(
|
||||
when (initialLoad) {
|
||||
is ActiveDraftLoadResult.Error ->
|
||||
initialLoad.message
|
||||
|
||||
ActiveDraftLoadResult.None ->
|
||||
"Aucune séance en cours."
|
||||
|
||||
is ActiveDraftLoadResult.Loaded ->
|
||||
initialLoad.warning
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
var confirmingDiscard by
|
||||
remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
val persistDraft:
|
||||
(ActiveSessionDraft, String?) -> Unit =
|
||||
{ updated, successMessage ->
|
||||
when (
|
||||
val result =
|
||||
repository
|
||||
.saveActiveSessionDraft(
|
||||
updated
|
||||
)
|
||||
) {
|
||||
ActiveDraftMutationResult.Saved -> {
|
||||
activeDraft = updated
|
||||
message = successMessage
|
||||
}
|
||||
|
||||
is ActiveDraftMutationResult.Error -> {
|
||||
message =
|
||||
"Brouillon non sauvegardé : " +
|
||||
result.message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TrainlogScreen(
|
||||
|
|
@ -96,18 +121,52 @@ fun SessionScreen(
|
|||
TrainlogAction(
|
||||
label = "< Retour",
|
||||
description =
|
||||
"Revenir à l'accueil.",
|
||||
"Revenir à l'accueil sans supprimer la séance en cours.",
|
||||
/* CONTRACT: ordinary navigation never owns draft deletion. */
|
||||
onClick = onBack,
|
||||
accent = colors.muted,
|
||||
)
|
||||
|
||||
if (activeDraft == null) {
|
||||
TrainlogFrame(
|
||||
title = "ERREUR"
|
||||
) {
|
||||
TrainlogInfo(
|
||||
text = message.orEmpty(),
|
||||
color = colors.error,
|
||||
)
|
||||
}
|
||||
return@TrainlogScreen
|
||||
}
|
||||
|
||||
val currentDraft = activeDraft!!
|
||||
|
||||
val lastWriteFailed =
|
||||
message?.startsWith(
|
||||
"Brouillon non sauvegardé"
|
||||
) == true
|
||||
TrainlogInfo(
|
||||
text =
|
||||
if (lastWriteFailed) {
|
||||
"Dernière modification non sauvegardée."
|
||||
} else {
|
||||
"Séance sauvegardée localement."
|
||||
},
|
||||
color =
|
||||
if (lastWriteFailed) {
|
||||
colors.error
|
||||
} else {
|
||||
colors.muted
|
||||
},
|
||||
)
|
||||
|
||||
TrainlogFrame(
|
||||
title = "TYPE DE SEANCE"
|
||||
) {
|
||||
TrainlogAction(
|
||||
label =
|
||||
if (
|
||||
sessionType ==
|
||||
currentDraft.sessionType ==
|
||||
SessionType.TRAINING
|
||||
) {
|
||||
"[✓] Entraînement"
|
||||
|
|
@ -118,7 +177,7 @@ fun SessionScreen(
|
|||
"Séance normale de travail.",
|
||||
accent =
|
||||
if (
|
||||
sessionType ==
|
||||
currentDraft.sessionType ==
|
||||
SessionType.TRAINING
|
||||
) {
|
||||
colors.success
|
||||
|
|
@ -126,15 +185,20 @@ fun SessionScreen(
|
|||
colors.muted
|
||||
},
|
||||
onClick = {
|
||||
sessionType =
|
||||
SessionType.TRAINING
|
||||
persistDraft(
|
||||
currentDraft.copy(
|
||||
sessionType =
|
||||
SessionType.TRAINING
|
||||
),
|
||||
null,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
TrainlogAction(
|
||||
label =
|
||||
if (
|
||||
sessionType ==
|
||||
currentDraft.sessionType ==
|
||||
SessionType.MAX_TEST
|
||||
) {
|
||||
"[✓] Test max"
|
||||
|
|
@ -145,7 +209,7 @@ fun SessionScreen(
|
|||
"Séance explicitement dédiée à une mesure de max.",
|
||||
accent =
|
||||
if (
|
||||
sessionType ==
|
||||
currentDraft.sessionType ==
|
||||
SessionType.MAX_TEST
|
||||
) {
|
||||
colors.warning
|
||||
|
|
@ -153,8 +217,13 @@ fun SessionScreen(
|
|||
colors.muted
|
||||
},
|
||||
onClick = {
|
||||
sessionType =
|
||||
SessionType.MAX_TEST
|
||||
persistDraft(
|
||||
currentDraft.copy(
|
||||
sessionType =
|
||||
SessionType.MAX_TEST
|
||||
),
|
||||
null,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -166,7 +235,7 @@ fun SessionScreen(
|
|||
text =
|
||||
"Type : " +
|
||||
if (
|
||||
sessionType ==
|
||||
currentDraft.sessionType ==
|
||||
SessionType.MAX_TEST
|
||||
) {
|
||||
"TEST MAX"
|
||||
|
|
@ -175,7 +244,7 @@ fun SessionScreen(
|
|||
},
|
||||
color =
|
||||
if (
|
||||
sessionType ==
|
||||
currentDraft.sessionType ==
|
||||
SessionType.MAX_TEST
|
||||
) {
|
||||
colors.warning
|
||||
|
|
@ -185,13 +254,13 @@ fun SessionScreen(
|
|||
)
|
||||
|
||||
if (
|
||||
draftExercises.isEmpty()
|
||||
currentDraft.exercises.isEmpty()
|
||||
) {
|
||||
TrainlogInfo(
|
||||
"Aucun exercice ajouté."
|
||||
)
|
||||
} else {
|
||||
draftExercises
|
||||
currentDraft.exercises
|
||||
.forEachIndexed {
|
||||
index,
|
||||
draft ->
|
||||
|
|
@ -214,19 +283,18 @@ fun SessionScreen(
|
|||
accent =
|
||||
colors.error,
|
||||
onClick = {
|
||||
draftExercises =
|
||||
draftExercises
|
||||
.filterIndexed {
|
||||
itemIndex,
|
||||
_ ->
|
||||
itemIndex !=
|
||||
index
|
||||
}
|
||||
|
||||
sessionRevision += 1
|
||||
|
||||
message =
|
||||
"Exercice retiré de la séance."
|
||||
persistDraft(
|
||||
currentDraft.copy(
|
||||
exercises =
|
||||
currentDraft.exercises
|
||||
.filterIndexed {
|
||||
itemIndex,
|
||||
_ ->
|
||||
itemIndex != index
|
||||
}
|
||||
),
|
||||
"Exercice retiré de la séance.",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -249,7 +317,7 @@ fun SessionScreen(
|
|||
exercise ->
|
||||
|
||||
val alreadyAdded =
|
||||
draftExercises.any {
|
||||
currentDraft.exercises.any {
|
||||
it.exercise.exerciseId ==
|
||||
exercise.exerciseId
|
||||
}
|
||||
|
|
@ -258,7 +326,8 @@ fun SessionScreen(
|
|||
exercise =
|
||||
exercise,
|
||||
selected =
|
||||
selectedExercise
|
||||
currentDraft.form
|
||||
.selectedExercise
|
||||
?.exerciseId ==
|
||||
exercise.exerciseId,
|
||||
disabled =
|
||||
|
|
@ -267,10 +336,16 @@ fun SessionScreen(
|
|||
if (
|
||||
!alreadyAdded
|
||||
) {
|
||||
selectedExercise =
|
||||
exercise
|
||||
|
||||
message = null
|
||||
persistDraft(
|
||||
currentDraft.copy(
|
||||
form =
|
||||
currentDraft.form.copy(
|
||||
selectedExercise =
|
||||
exercise
|
||||
)
|
||||
),
|
||||
null,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
@ -278,32 +353,47 @@ fun SessionScreen(
|
|||
}
|
||||
}
|
||||
|
||||
if (
|
||||
selectedExercise != null
|
||||
) {
|
||||
val editingExercise =
|
||||
currentDraft.form.selectedExercise
|
||||
|
||||
if (editingExercise != null) {
|
||||
SessionExerciseForm(
|
||||
key =
|
||||
selectedExercise!!
|
||||
.exerciseId,
|
||||
editingExercise.exerciseId,
|
||||
exercise =
|
||||
selectedExercise!!,
|
||||
editingExercise,
|
||||
initialForm =
|
||||
currentDraft.form,
|
||||
onFormChanged = {
|
||||
form ->
|
||||
persistDraft(
|
||||
currentDraft.copy(
|
||||
form = form
|
||||
),
|
||||
null,
|
||||
)
|
||||
},
|
||||
onCancel = {
|
||||
selectedExercise =
|
||||
null
|
||||
persistDraft(
|
||||
currentDraft.copy(
|
||||
form =
|
||||
SessionDraftForm()
|
||||
),
|
||||
null,
|
||||
)
|
||||
},
|
||||
onAdd = {
|
||||
draft ->
|
||||
draftExercises =
|
||||
draftExercises +
|
||||
draft
|
||||
|
||||
selectedExercise =
|
||||
null
|
||||
|
||||
sessionRevision += 1
|
||||
|
||||
message =
|
||||
"Exercice ajouté à la séance."
|
||||
persistDraft(
|
||||
currentDraft.copy(
|
||||
exercises =
|
||||
currentDraft.exercises +
|
||||
draft,
|
||||
form =
|
||||
SessionDraftForm(),
|
||||
),
|
||||
"Exercice ajouté à la séance.",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -326,59 +416,87 @@ fun SessionScreen(
|
|||
TrainlogFrame(
|
||||
title = "ENREGISTREMENT",
|
||||
active =
|
||||
draftExercises.isNotEmpty(),
|
||||
currentDraft.exercises.isNotEmpty(),
|
||||
) {
|
||||
TrainlogAction(
|
||||
label =
|
||||
"Enregistrer la séance",
|
||||
description =
|
||||
"${draftExercises.size} exercice(s) dans la séance.",
|
||||
"${currentDraft.exercises.size} exercice(s) dans la séance.",
|
||||
accent =
|
||||
colors.success,
|
||||
onClick = {
|
||||
when (
|
||||
val result =
|
||||
repository
|
||||
.saveSession(
|
||||
SessionDraft(
|
||||
exercises =
|
||||
draftExercises,
|
||||
sessionType =
|
||||
sessionType,
|
||||
)
|
||||
)
|
||||
repository.finalizeActiveSessionDraft()
|
||||
) {
|
||||
is SaveSessionResult.Saved -> {
|
||||
draftExercises =
|
||||
emptyList()
|
||||
|
||||
selectedExercise =
|
||||
null
|
||||
|
||||
sessionType =
|
||||
SessionType.TRAINING
|
||||
|
||||
sessionRevision += 1
|
||||
|
||||
message =
|
||||
"Séance enregistrée."
|
||||
|
||||
is FinalizeActiveDraftResult.Saved -> {
|
||||
onSessionSaved()
|
||||
onBack()
|
||||
}
|
||||
|
||||
SaveSessionResult.Invalid -> {
|
||||
message =
|
||||
"Séance invalide."
|
||||
is FinalizeActiveDraftResult.Invalid -> {
|
||||
message = result.message
|
||||
}
|
||||
|
||||
SaveSessionResult.DatabaseError -> {
|
||||
is FinalizeActiveDraftResult.DatabaseError -> {
|
||||
message =
|
||||
"Erreur base locale."
|
||||
"Échec de finalisation, brouillon conservé : " +
|
||||
result.message
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
TrainlogAction(
|
||||
label = "Supprimer la séance en cours",
|
||||
description =
|
||||
"Supprimer uniquement ce brouillon local.",
|
||||
accent = colors.error,
|
||||
onClick = {
|
||||
confirmingDiscard = true
|
||||
},
|
||||
)
|
||||
|
||||
if (confirmingDiscard) {
|
||||
TrainlogInfo(
|
||||
text =
|
||||
"Cette suppression n'ajoutera rien à l'historique.",
|
||||
color = colors.error,
|
||||
)
|
||||
TrainlogAction(
|
||||
label = "Confirmer la suppression",
|
||||
description =
|
||||
"Supprimer définitivement la séance en cours.",
|
||||
accent = colors.error,
|
||||
onClick = {
|
||||
when (
|
||||
val result =
|
||||
repository
|
||||
.discardActiveSessionDraft()
|
||||
) {
|
||||
ActiveDraftMutationResult.Saved -> {
|
||||
confirmingDiscard = false
|
||||
onBack()
|
||||
}
|
||||
|
||||
is ActiveDraftMutationResult.Error -> {
|
||||
message = result.message
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
TrainlogAction(
|
||||
label = "Annuler",
|
||||
description =
|
||||
"Conserver la séance en cours.",
|
||||
accent = colors.muted,
|
||||
onClick = {
|
||||
confirmingDiscard = false
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
message != null
|
||||
) {
|
||||
|
|
@ -387,8 +505,6 @@ fun SessionScreen(
|
|||
message.orEmpty(),
|
||||
color =
|
||||
if (
|
||||
message ==
|
||||
"Séance enregistrée." ||
|
||||
message ==
|
||||
"Exercice ajouté à la séance." ||
|
||||
message ==
|
||||
|
|
@ -479,6 +595,8 @@ private fun CatalogChoice(
|
|||
private fun SessionExerciseForm(
|
||||
key: String,
|
||||
exercise: ExerciseProfile,
|
||||
initialForm: SessionDraftForm,
|
||||
onFormChanged: (SessionDraftForm) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
onAdd:
|
||||
(SessionExerciseDraft) ->
|
||||
|
|
@ -489,27 +607,37 @@ private fun SessionExerciseForm(
|
|||
|
||||
var setCountText by
|
||||
remember(key) {
|
||||
mutableStateOf("3")
|
||||
mutableStateOf(
|
||||
initialForm.setCountText
|
||||
)
|
||||
}
|
||||
|
||||
var repsText by
|
||||
remember(key) {
|
||||
mutableStateOf("3x10")
|
||||
mutableStateOf(
|
||||
initialForm.repsText
|
||||
)
|
||||
}
|
||||
|
||||
var durationText by
|
||||
remember(key) {
|
||||
mutableStateOf("30")
|
||||
mutableStateOf(
|
||||
initialForm.durationText
|
||||
)
|
||||
}
|
||||
|
||||
var speedText by
|
||||
remember(key) {
|
||||
mutableStateOf("")
|
||||
mutableStateOf(
|
||||
initialForm.speedText
|
||||
)
|
||||
}
|
||||
|
||||
var distanceText by
|
||||
remember(key) {
|
||||
mutableStateOf("")
|
||||
mutableStateOf(
|
||||
initialForm.distanceText
|
||||
)
|
||||
}
|
||||
|
||||
var error by
|
||||
|
|
@ -547,6 +675,16 @@ private fun SessionExerciseForm(
|
|||
onValueChange = {
|
||||
repsText = it
|
||||
error = null
|
||||
onFormChanged(
|
||||
currentForm(
|
||||
exercise,
|
||||
setCountText,
|
||||
it,
|
||||
durationText,
|
||||
speedText,
|
||||
distanceText,
|
||||
)
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -565,6 +703,16 @@ private fun SessionExerciseForm(
|
|||
onValueChange = {
|
||||
setCountText = it
|
||||
error = null
|
||||
onFormChanged(
|
||||
currentForm(
|
||||
exercise,
|
||||
it,
|
||||
repsText,
|
||||
durationText,
|
||||
speedText,
|
||||
distanceText,
|
||||
)
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -576,6 +724,16 @@ private fun SessionExerciseForm(
|
|||
onValueChange = {
|
||||
durationText = it
|
||||
error = null
|
||||
onFormChanged(
|
||||
currentForm(
|
||||
exercise,
|
||||
setCountText,
|
||||
repsText,
|
||||
it,
|
||||
speedText,
|
||||
distanceText,
|
||||
)
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -588,6 +746,16 @@ private fun SessionExerciseForm(
|
|||
onValueChange = {
|
||||
durationText = it
|
||||
error = null
|
||||
onFormChanged(
|
||||
currentForm(
|
||||
exercise,
|
||||
setCountText,
|
||||
repsText,
|
||||
it,
|
||||
speedText,
|
||||
distanceText,
|
||||
)
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -604,6 +772,16 @@ private fun SessionExerciseForm(
|
|||
onValueChange = {
|
||||
speedText = it
|
||||
error = null
|
||||
onFormChanged(
|
||||
currentForm(
|
||||
exercise,
|
||||
setCountText,
|
||||
repsText,
|
||||
durationText,
|
||||
it,
|
||||
distanceText,
|
||||
)
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -621,6 +799,16 @@ private fun SessionExerciseForm(
|
|||
onValueChange = {
|
||||
distanceText = it
|
||||
error = null
|
||||
onFormChanged(
|
||||
currentForm(
|
||||
exercise,
|
||||
setCountText,
|
||||
repsText,
|
||||
durationText,
|
||||
speedText,
|
||||
it,
|
||||
)
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -682,6 +870,23 @@ private fun SessionExerciseForm(
|
|||
}
|
||||
}
|
||||
|
||||
private fun currentForm(
|
||||
exercise: ExerciseProfile,
|
||||
setCountText: String,
|
||||
repsText: String,
|
||||
durationText: String,
|
||||
speedText: String,
|
||||
distanceText: String,
|
||||
): SessionDraftForm =
|
||||
SessionDraftForm(
|
||||
selectedExercise = exercise,
|
||||
setCountText = setCountText,
|
||||
repsText = repsText,
|
||||
durationText = durationText,
|
||||
speedText = speedText,
|
||||
distanceText = distanceText,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun SessionNumberField(
|
||||
label: String,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import androidx.compose.runtime.mutableIntStateOf
|
|||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.labfytools.trainlog.data.ActiveDraftLoadResult
|
||||
import com.labfytools.trainlog.data.ActiveDraftMutationResult
|
||||
import com.labfytools.trainlog.data.TrainlogRepository
|
||||
import com.labfytools.trainlog.data.SyncExporter
|
||||
import com.labfytools.trainlog.data.CatalogInboxResult
|
||||
|
|
@ -52,6 +54,16 @@ fun TrainlogApp(
|
|||
mutableIntStateOf(0)
|
||||
}
|
||||
|
||||
var draftRevision by
|
||||
remember {
|
||||
mutableIntStateOf(0)
|
||||
}
|
||||
|
||||
var draftMessage by
|
||||
remember {
|
||||
mutableStateOf<String?>(null)
|
||||
}
|
||||
|
||||
var selectedSessionId by
|
||||
remember {
|
||||
mutableStateOf<String?>(
|
||||
|
|
@ -99,11 +111,74 @@ fun TrainlogApp(
|
|||
}
|
||||
|
||||
when (screen) {
|
||||
TrainlogScreenId.HOME ->
|
||||
TrainlogScreenId.HOME -> {
|
||||
val draftLoad =
|
||||
remember(
|
||||
draftRevision,
|
||||
catalogRevision,
|
||||
) {
|
||||
repository.loadActiveSessionDraft()
|
||||
}
|
||||
|
||||
HomeScreen(
|
||||
activeDraft =
|
||||
(draftLoad as?
|
||||
ActiveDraftLoadResult.Loaded)
|
||||
?.draft,
|
||||
draftError =
|
||||
draftMessage
|
||||
?: (draftLoad as?
|
||||
ActiveDraftLoadResult.Error)
|
||||
?.message
|
||||
?: (draftLoad as?
|
||||
ActiveDraftLoadResult.Loaded)
|
||||
?.warning,
|
||||
onSession = {
|
||||
screen =
|
||||
TrainlogScreenId.SESSION
|
||||
when (draftLoad) {
|
||||
is ActiveDraftLoadResult.Loaded -> {
|
||||
draftMessage = null
|
||||
screen = TrainlogScreenId.SESSION
|
||||
}
|
||||
|
||||
ActiveDraftLoadResult.None -> {
|
||||
when (
|
||||
val result =
|
||||
repository
|
||||
.startActiveSessionDraft()
|
||||
) {
|
||||
ActiveDraftMutationResult.Saved -> {
|
||||
draftMessage = null
|
||||
draftRevision += 1
|
||||
screen =
|
||||
TrainlogScreenId.SESSION
|
||||
}
|
||||
|
||||
is ActiveDraftMutationResult.Error -> {
|
||||
draftMessage = result.message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is ActiveDraftLoadResult.Error -> {
|
||||
draftMessage = draftLoad.message
|
||||
}
|
||||
}
|
||||
},
|
||||
onDiscardDraft = {
|
||||
when (
|
||||
val result =
|
||||
repository
|
||||
.discardActiveSessionDraft()
|
||||
) {
|
||||
ActiveDraftMutationResult.Saved -> {
|
||||
draftMessage = null
|
||||
draftRevision += 1
|
||||
}
|
||||
|
||||
is ActiveDraftMutationResult.Error -> {
|
||||
draftMessage = result.message
|
||||
}
|
||||
}
|
||||
},
|
||||
onExercise = {
|
||||
exerciseReturnTarget =
|
||||
|
|
@ -125,6 +200,7 @@ fun TrainlogApp(
|
|||
TrainlogScreenId.SYNC
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
TrainlogScreenId.SESSION ->
|
||||
SessionScreen(
|
||||
|
|
@ -132,6 +208,9 @@ fun TrainlogApp(
|
|||
catalogRevision =
|
||||
catalogRevision,
|
||||
onBack = {
|
||||
/* WHY: Back changes routing only; the repository remains
|
||||
* the canonical owner of the in-progress workout. */
|
||||
draftRevision += 1
|
||||
screen =
|
||||
TrainlogScreenId.HOME
|
||||
},
|
||||
|
|
@ -144,6 +223,7 @@ fun TrainlogApp(
|
|||
},
|
||||
onSessionSaved = {
|
||||
exporter.exportMobileBundle()
|
||||
draftRevision += 1
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ package com.labfytools.trainlog.ui
|
|||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
|
|
@ -27,7 +26,6 @@ import androidx.compose.runtime.getValue
|
|||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
|
@ -38,15 +36,6 @@ import androidx.compose.ui.unit.sp
|
|||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||
import com.labfytools.trainlog.ui.theme.TrainlogTypography
|
||||
|
||||
private val FullAsciiBanner =
|
||||
"""
|
||||
TTTTT RRRR AAA IIIII N N L OOO GGG
|
||||
T R R A A I NN N L O O G
|
||||
T RRRR AAAAA I N N N L O O G GG
|
||||
T R R A A I N NN L O O G G
|
||||
T R R A A IIIII N N LLLLL OOO GGG
|
||||
""".trimIndent()
|
||||
|
||||
@Composable
|
||||
fun TrainlogScreen(
|
||||
subtitle: String,
|
||||
|
|
@ -87,53 +76,33 @@ private fun TrainlogBanner(
|
|||
val colors =
|
||||
LocalTrainlogColors.current
|
||||
|
||||
BoxWithConstraints(
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 18.dp)
|
||||
) {
|
||||
val wide =
|
||||
maxWidth >= 560.dp
|
||||
/* WHY: TUI and Android share this compact plaque rather than separate
|
||||
* brand treatments. The terminal box becomes flat spacing on touch. */
|
||||
BasicText(
|
||||
text = "◆ TRAINLOG ◆",
|
||||
style =
|
||||
TrainlogTypography.banner.copy(
|
||||
color = colors.accent,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 21.sp,
|
||||
),
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment =
|
||||
Alignment.Start,
|
||||
) {
|
||||
BasicText(
|
||||
text =
|
||||
if (wide) {
|
||||
FullAsciiBanner
|
||||
} else {
|
||||
"T R A I N L O G"
|
||||
},
|
||||
style =
|
||||
TrainlogTypography.banner.copy(
|
||||
color = colors.accent,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize =
|
||||
if (wide) {
|
||||
14.sp
|
||||
} else {
|
||||
21.sp
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
BasicText(
|
||||
text = subtitle,
|
||||
modifier =
|
||||
Modifier.padding(top = 5.dp),
|
||||
style =
|
||||
TrainlogTypography.small.copy(
|
||||
color = colors.muted,
|
||||
fontWeight =
|
||||
FontWeight.Bold,
|
||||
),
|
||||
)
|
||||
}
|
||||
BasicText(
|
||||
text = subtitle,
|
||||
modifier = Modifier.padding(top = 5.dp),
|
||||
style =
|
||||
TrainlogTypography.small.copy(
|
||||
color = colors.muted,
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Accueil
|
||||
├── Reprendre la séance en cours (si un brouillon existe)
|
||||
├── Enregistrer une séance
|
||||
├── Enregistrer un exercice
|
||||
├── Enregistrer des mensurations
|
||||
|
|
@ -24,7 +25,7 @@ Accueil
|
|||
Android local database version:
|
||||
|
||||
```text
|
||||
3
|
||||
4
|
||||
```
|
||||
|
||||
Domain tables cover:
|
||||
|
|
@ -40,6 +41,11 @@ body_observations
|
|||
|
||||
This database is Android-local. It is not copied to the PC.
|
||||
|
||||
Schema v4 adds `active_session_draft`, `draft_session_exercises`,
|
||||
`draft_performed_sets` and `draft_continuous_activity`. The additive v3 -> v4
|
||||
migration preserves catalog, completed sessions/actuals and body observations.
|
||||
Exactly one active draft is supported; it is separate from completed history.
|
||||
|
||||
## 4. Exercise catalog
|
||||
|
||||
Exercise creation records:
|
||||
|
|
@ -62,6 +68,25 @@ collisions.
|
|||
|
||||
An exercise may be created standalone or inline while building a session.
|
||||
|
||||
### Editing an exercise
|
||||
|
||||
Every existing catalog item exposes **Modifier**. Editing a name trims its
|
||||
input, recomputes `normalized_name`, and rejects a normalized-name collision.
|
||||
The row retains its existing `exercise_id`; naming is presentation metadata,
|
||||
not identity. Completed session rows and the active draft retain their catalog
|
||||
row relationship and immediately resolve the renamed display text after reopen.
|
||||
|
||||
Profile fields (`recording_mode`, `tracking_mode`, `data_fields`) are editable
|
||||
only while an exercise has no completed-session or active-draft reference. Once
|
||||
referenced, Android displays the lock and returns an explicit incompatible
|
||||
profile result rather than silently reinterpreting work or creating another
|
||||
exercise. Renaming remains available independently.
|
||||
|
||||
The shared Compose `TrainlogScreen` header is used by Accueil, Séance,
|
||||
Exercice, Mensurations, Historique, Détail séance and Sync. Its compact
|
||||
`◆ TRAINLOG ◆` accent plaque and muted subtitle intentionally mirror the
|
||||
Notcurses TUI identity in a flat mobile layout.
|
||||
|
||||
## 5. Session recording
|
||||
|
||||
Stable session identity:
|
||||
|
|
@ -97,10 +122,34 @@ Continuous work does not create fake sets.
|
|||
|
||||
## 6. Session draft editing
|
||||
|
||||
Before a session is saved, an exercise already added to the draft can be
|
||||
removed.
|
||||
The repository durably saves every meaningful mutation, including session type,
|
||||
exercise selection/addition/removal, actual values and raw form edits. Partial
|
||||
text such as `4,5,6,` is retained without normalization. A failed write displays
|
||||
a specific error and does not claim the latest change was saved.
|
||||
|
||||
Removing one exercise does not alter the exercise catalog entry itself.
|
||||
Home shows **Reprendre la séance en cours** and an exercise-count/type summary.
|
||||
The ordinary new-session action opens an existing draft without overwriting it.
|
||||
Back returns Home and preserves the draft. Backgrounding, switching apps,
|
||||
Activity/configuration recreation, background process death and force-stop with
|
||||
relaunch preserve the draft; these paths were validated on the Samsung SM_G990B.
|
||||
|
||||
**Retirer <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
|
||||
|
||||
|
|
@ -155,6 +204,10 @@ session, body-observation, and PC-catalog updates.
|
|||
|
||||
The user does not need a separate manual export step before synchronization.
|
||||
|
||||
An active draft is never included in completed history, session detail or this
|
||||
snapshot. Synchronization continues to exchange completed data while the draft
|
||||
stays local; no draft fields were added to the frozen mobile artifact.
|
||||
|
||||
## 10. PC catalog access
|
||||
|
||||
PC-created files are accessed through a persistent Storage Access Framework
|
||||
|
|
@ -232,6 +285,14 @@ adb install -r app/build/outputs/apk/debug/app-debug.apk
|
|||
|
||||
`local.properties` is local machine configuration and must not be committed.
|
||||
|
||||
Host regression suite: 8 tests. Device instrumentation: 5 tests (2 repository,
|
||||
3 production-screen UI tests using an isolated database and no shared export).
|
||||
The real device matrix additionally exercised production `MainActivity`,
|
||||
including verified process exit with `am kill`, force-stop, configuration
|
||||
relaunch, raw-form recovery, removal, discard and unchanged user data. Final-save
|
||||
UI checks use isolated data so fictitious workouts do not enter user history.
|
||||
See [tests](tests.md) for commands and the precise validation boundary.
|
||||
|
||||
## 14. Non-goals
|
||||
|
||||
Android is not intended to own:
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ Android local SQLite is a capture store, not a synchronization format.
|
|||
Responsibilities:
|
||||
|
||||
- exercise catalog entry;
|
||||
- stable-ID exercise rename/editing;
|
||||
- workout-session recording;
|
||||
- performed set entry;
|
||||
- continuous-activity entry;
|
||||
|
|
@ -64,7 +65,9 @@ The C17 core owns:
|
|||
|
||||
### TUI
|
||||
|
||||
The ncursesw layer owns interaction and rendering.
|
||||
The Notcurses layer owns interaction and rendering. It is confined to the
|
||||
desktop executable; persistence, synchronization, and core services have no
|
||||
terminal-library dependency.
|
||||
|
||||
It consumes core services for:
|
||||
|
||||
|
|
@ -132,13 +135,37 @@ body_observations
|
|||
|
||||
### Android
|
||||
|
||||
Android has an independent local SQLite schema.
|
||||
Android has an independent local SQLite schema, currently v4.
|
||||
|
||||
It mirrors domain concepts needed for capture, but its schema version is not
|
||||
coupled to the desktop schema.
|
||||
|
||||
Synchronization exchanges domain artifacts rather than database files.
|
||||
|
||||
`TrainlogRepository` owns a singleton active-session draft, its ordered exercise
|
||||
and actual-value children, and raw form text. Compose sends meaningful mutations
|
||||
to that repository; lifecycle callbacks are not the sole persistence boundary.
|
||||
Normal navigation never deletes the draft. Home restores the resume affordance
|
||||
from SQLite after process recreation.
|
||||
|
||||
Drafts use separate tables from completed sessions and are never export sources.
|
||||
Finalization inserts the completed session and deletes the draft in one
|
||||
transaction; failures retain the draft. Catalog row references preserve draft
|
||||
identity through existing PC-catalog reconciliation. Missing editing-selection
|
||||
recovery preserves the raw fields and added exercises with a specific warning.
|
||||
|
||||
`TrainlogRepository.editExercise()` owns all Android exercise edits. It changes
|
||||
the display name and normalized form in the existing catalog row identified by
|
||||
`exercise_id`; foreign-key ownership consequently preserves completed history
|
||||
and active drafts. A profile edit is admitted only before that row is referenced
|
||||
by either completed or active-draft data. Android and desktop same-ID catalog
|
||||
reconciliation apply name metadata in place and reject a collision with a
|
||||
different stable ID.
|
||||
|
||||
Compose presentation has one `TrainlogScreen` header component for every page.
|
||||
It uses the TUI's compact accent `◆ TRAINLOG ◆` plaque and muted context line;
|
||||
screen navigation and data ownership remain independent from the header.
|
||||
|
||||
## 5. Compatibility boundaries
|
||||
|
||||
### Frozen Trainlog JSON v1
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Current implementation state
|
||||
|
||||
Canonical snapshot: 2026-09-06.
|
||||
Canonical snapshot: 2026-09-07.
|
||||
|
||||
This document is the compact source of truth for the implemented Trainlog
|
||||
baseline. Detailed behavior belongs in the topic-specific documents.
|
||||
|
|
@ -15,7 +15,23 @@ GATE_2_PERSISTENCE_AND_USABLE_TUI=PASS
|
|||
TRAINLOG_FORMAT_V1=FROZEN
|
||||
|
||||
DESKTOP_SCHEMA_V5=PASS
|
||||
ANDROID_LOCAL_DATABASE_V3=PASS
|
||||
ANDROID_LOCAL_DATABASE_V4=PASS
|
||||
ANDROID_SESSION_DRAFT_V1=PASS
|
||||
ANDROID_DRAFT_DURABLE=PASS
|
||||
ANDROID_DRAFT_BACKGROUND_SURVIVAL=PASS
|
||||
ANDROID_DRAFT_PROCESS_DEATH_SURVIVAL=PASS
|
||||
ANDROID_DRAFT_FORCE_STOP_SURVIVAL=PASS
|
||||
ANDROID_SESSION_RESUME=PASS
|
||||
ANDROID_DRAFT_FORM_RESTORE=PASS
|
||||
ANDROID_DRAFT_EXERCISE_REMOVE=PASS
|
||||
ANDROID_DRAFT_DISCARD=PASS
|
||||
ANDROID_DRAFT_FINALIZE_ATOMIC=PASS
|
||||
ANDROID_DRAFT_NOT_EXPORTED_AS_SESSION=PASS
|
||||
EXERCISE_EDIT_V1=PASS
|
||||
EXERCISE_RENAME_STABLE_ID=PASS
|
||||
ANDROID_BANNER_PARITY_V1=PASS
|
||||
ANDROID_INSTALL_ADB=PASS
|
||||
ANDROID_USER_DATA_PRESERVED=PASS
|
||||
|
||||
PROFILE_AWARE_EXERCISES=PASS
|
||||
CONTINUOUS_ACTIVITY=PASS
|
||||
|
|
@ -31,7 +47,7 @@ ANDROID_SYNC_RECEIPT=PASS
|
|||
TUI_SYNC_LOG_SHOW=PASS
|
||||
BIDIRECTIONAL_SYNC_V1=PASS
|
||||
|
||||
DESKTOP_TESTS=21/21 PASS
|
||||
DESKTOP_TESTS=22/22 PASS
|
||||
ANDROID_BUILD=PASS
|
||||
HARDWARE_SYNC_VALIDATION=PASS
|
||||
```
|
||||
|
|
@ -40,7 +56,7 @@ HARDWARE_SYNC_VALIDATION=PASS
|
|||
|
||||
Implemented:
|
||||
|
||||
- C17/ncursesw TUI;
|
||||
- C17/Notcurses true-color TUI (72x20 minimum, UTF-8 prompts, resize fallback);
|
||||
- SQLite schema v5;
|
||||
- direct session entry;
|
||||
- persisted session detail and editing;
|
||||
|
|
@ -71,8 +87,11 @@ Primary navigation:
|
|||
Implemented:
|
||||
|
||||
- native Kotlin/Compose application;
|
||||
- local SQLite database v3;
|
||||
- local SQLite database v4, with non-destructive v3 -> v4 migration;
|
||||
- one durable active-session draft, Home resume and raw-form restoration;
|
||||
- explicit confirmed discard and atomic completed-save/draft-clear;
|
||||
- exercise creation;
|
||||
- stable-ID exercise rename/editing with referenced-profile protection;
|
||||
- inline exercise creation during session entry;
|
||||
- profile-aware session recording;
|
||||
- heterogeneous repetition-set entry;
|
||||
|
|
@ -85,6 +104,17 @@ Implemented:
|
|||
- Android-triggered synchronization request;
|
||||
- synchronization receipt handling.
|
||||
|
||||
The Android catalog exposes **Modifier** for every existing exercise. A rename
|
||||
updates `name` and `normalized_name` in the original row, never creates an ID,
|
||||
and remains valid for completed session and active-draft references. A profile
|
||||
change is only accepted while the row has neither completed-session nor draft
|
||||
references. Same-ID catalog reconciliation updates display metadata in place in
|
||||
both Android and desktop import directions.
|
||||
|
||||
All Android screens use the shared compact `◆ TRAINLOG ◆` header: the
|
||||
Notcurses accent, muted context line, and flat touch layout reproduce the TUI
|
||||
plaque without literal terminal box drawing.
|
||||
|
||||
## Synchronization
|
||||
|
||||
Canonical exchange directory:
|
||||
|
|
@ -120,7 +150,7 @@ No mounted Android filesystem is required.
|
|||
Desktop:
|
||||
|
||||
```text
|
||||
21/21 Meson tests PASS
|
||||
22/22 Meson tests PASS
|
||||
frozen JSON validator PASS
|
||||
import-contract validator PASS
|
||||
git diff --check PASS
|
||||
|
|
@ -130,13 +160,18 @@ Android:
|
|||
|
||||
```text
|
||||
assembleDebug PASS
|
||||
host repository tests 8/8 PASS
|
||||
device instrumentation 5/5 PASS
|
||||
real Samsung background/process-death/force-stop/resume matrix PASS
|
||||
real migration and original user-data preservation PASS
|
||||
real Samsung request -> daemon -> bidirectional sync -> receipt PASS
|
||||
multiple distinct request IDs consumed once each PASS
|
||||
```
|
||||
|
||||
## Current implementation cursor
|
||||
|
||||
No new feature is frozen by this documentation cleanup.
|
||||
The Android draft correction is implemented, device-validated and reviewed.
|
||||
No product-roadmap ordering changes were made.
|
||||
|
||||
```text
|
||||
MEASURED_MAX_V1=PASS
|
||||
|
|
@ -156,7 +191,7 @@ MEASURED_MAX_ONLY_FROM_MAX_TEST=PASS
|
|||
WORKING_LOAD_PERCENTAGES=PASS
|
||||
ASSISTANCE_DIRECTION_AWARE=PASS
|
||||
ANDROID_MAX_TEST_SESSION=PASS
|
||||
DESKTOP_TESTS=21/21 PASS
|
||||
DESKTOP_TESTS=22/22 PASS
|
||||
```
|
||||
|
||||
A measured maximum is derived only from explicit `max_test` sessions. Ordinary
|
||||
|
|
@ -177,7 +212,7 @@ BODY_COMPOSITION_ESTIMATE=PASS
|
|||
BODY_PROPORTION_RATIOS=PASS
|
||||
BODY_SYMMETRY_ANALYTICS=PASS
|
||||
NO_ESTIMATE_PERSISTENCE=PASS
|
||||
DESKTOP_TESTS=21/21 PASS
|
||||
DESKTOP_TESTS=22/22 PASS
|
||||
```
|
||||
|
||||
Android remains capture-only for this feature.
|
||||
|
|
|
|||
|
|
@ -258,6 +258,27 @@ distance km
|
|||
|
||||
The Android SQLite database is independent.
|
||||
|
||||
Current Android-local version: **4**. The explicit v3 -> v4 migration only adds
|
||||
structured draft tables; it does not rebuild or delete existing domain tables.
|
||||
|
||||
| Table | Ownership |
|
||||
| --- | --- |
|
||||
| `active_session_draft` | Single `id = 1` row, session type, selected catalog row, raw form text, update time |
|
||||
| `draft_session_exercises` | Ordered draft exercises and profile snapshots |
|
||||
| `draft_performed_sets` | Ordered heterogeneous repetition or duration actuals |
|
||||
| `draft_continuous_activity` | Duration and configured speed/distance without synthetic sets |
|
||||
|
||||
Foreign keys remain enabled. Draft deletion cascades only through draft child
|
||||
tables; it cannot delete catalog entries or completed history. The repository
|
||||
commits completed-session insertion and draft removal together, rolling back
|
||||
both on failure. Repeating finalization after success cannot create another
|
||||
completed session. Completed `started_at` semantics are unchanged by this repair.
|
||||
|
||||
Migration tests use a real v3-shaped fixture. The physical Samsung upgrade also
|
||||
preserved every existing domain row, with successful integrity and foreign-key
|
||||
checks. Device backup files are outside the repository; no SQLite files are
|
||||
used as synchronization artifacts.
|
||||
|
||||
Desktop and Android schema versions are not required to match.
|
||||
|
||||
Do not synchronize SQLite database files.
|
||||
|
|
@ -275,7 +296,7 @@ Migration-specific regression coverage includes:
|
|||
schema_v5_migration
|
||||
```
|
||||
|
||||
The current normal suite contains 19 tests.
|
||||
The current normal desktop suite contains 22 tests.
|
||||
|
||||
## 11. Measured-max derivation
|
||||
|
||||
|
|
|
|||
|
|
@ -524,9 +524,11 @@ For each incoming exercise, the TUI reconciles against the local canonical catal
|
|||
|
||||
Reuse the existing exercise.
|
||||
|
||||
If the normalized display name differs, import is allowed but a non-fatal metadata warning is surfaced.
|
||||
|
||||
The canonical local name is not silently changed.
|
||||
If the normalized display name differs, update `name` and `normalized_name` in
|
||||
the existing catalog row. This is a stable-ID rename: completed session
|
||||
references remain attached to the same row and no second exercise is created.
|
||||
If another identity already owns the incoming normalized name, reject the
|
||||
import as an identity conflict.
|
||||
|
||||
### Same ID, different tracking mode
|
||||
|
||||
|
|
|
|||
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
|
||||
DESKTOP_SCHEMA_V5=PASS
|
||||
ANDROID_LOCAL_DATABASE_V3=PASS
|
||||
ANDROID_LOCAL_DATABASE_V4=PASS
|
||||
|
||||
DIRECT_MTP_TRANSPORT=PASS
|
||||
BIDIRECTIONAL_SYNC_V1=PASS
|
||||
|
|
@ -23,14 +23,19 @@ BIDIRECTIONAL_SYNC_V1=PASS
|
|||
VARIABLE_REPETITION_SETS=PASS
|
||||
MEASURED_MAX_V1=PASS
|
||||
BODY_ANALYTICS_V1=PASS
|
||||
EXERCISE_EDIT_V1=PASS
|
||||
ANDROID_BANNER_PARITY_V1=PASS
|
||||
|
||||
DESKTOP_TESTS=21/21 PASS
|
||||
DESKTOP_TESTS=22/22 PASS
|
||||
TUI_NOTCURSES_V1=PASS
|
||||
NCURSESW_REMOVED_FROM_ACTIVE_TUI=PASS
|
||||
NOTCURSES_TRUECOLOR_THEME=PASS
|
||||
```
|
||||
|
||||
The current product baseline includes:
|
||||
|
||||
- SQLite persistence and explicit migrations;
|
||||
- usable ncurses desktop TUI;
|
||||
- usable Notcurses desktop TUI;
|
||||
- native Android capture client;
|
||||
- exercise catalog;
|
||||
- profile-aware set and continuous activity;
|
||||
|
|
@ -43,8 +48,19 @@ The current product baseline includes:
|
|||
- bidirectional Android/PC synchronization;
|
||||
- shared synchronization engine and `trainlog-syncd`.
|
||||
|
||||
`EXERCISE_EDIT_V1` is a completed capture correction: Android permits
|
||||
stable-ID renames, protects referenced profiles, and reconciles same-ID display
|
||||
metadata without duplicates. `ANDROID_BANNER_PARITY_V1` is a presentation-only
|
||||
completed checkpoint: all Android screens share the Notcurses-derived compact
|
||||
header; it does not reorder the roadmap below.
|
||||
|
||||
## Product boundary
|
||||
|
||||
`ANDROID_SESSION_DRAFT_V1` is an implemented P0 capture-reliability correction:
|
||||
one durable active draft, Home resume, explicit discard and atomic completion.
|
||||
Host and device validation and the final tranche review pass. This repair
|
||||
does not introduce planning/templates or reorder the product roadmap below.
|
||||
|
||||
The intended split remains:
|
||||
|
||||
```text
|
||||
|
|
|
|||
|
|
@ -103,6 +103,13 @@ continuous
|
|||
|
||||
No synthetic set is created for continuous work.
|
||||
|
||||
Android-local active-session drafts are excluded from this snapshot and remain
|
||||
local during synchronization. Only successful atomic finalization makes a draft
|
||||
a completed exportable session. The v1 artifact has no draft fields or tables;
|
||||
catalog reconciliation preserves active draft references.
|
||||
Same-ID catalog entries may update display-name metadata in their existing
|
||||
catalog row; a rename never creates a second exercise identity.
|
||||
|
||||
## 5. Desktop mobile importer
|
||||
|
||||
Reference importer:
|
||||
|
|
|
|||
|
|
@ -79,15 +79,23 @@ Current normal suite:
|
|||
16 usb
|
||||
17 variable_sets
|
||||
18 schema_v5_migration
|
||||
19 mobile_import_variable_sets
|
||||
19 measured_max
|
||||
20 body_analytics
|
||||
21 terminal_input_event_type_policy
|
||||
22 mobile_import_variable_sets
|
||||
```
|
||||
|
||||
Validated checkpoint:
|
||||
|
||||
```text
|
||||
21/21 PASS
|
||||
22/22 PASS
|
||||
```
|
||||
|
||||
The desktop executable is additionally smoke-checked in isolated tmux PTYs at
|
||||
100x30, the exact 72x20 minimum, and the 60x15 fallback; a resize down/up must
|
||||
recover before a clean keyboard quit. Notcurses is verified as the executable's
|
||||
direct terminal dependency with `readelf -d`.
|
||||
|
||||
Notable regression coverage:
|
||||
|
||||
- transactional persisted-session replacement;
|
||||
|
|
@ -98,8 +106,12 @@ Notable regression coverage:
|
|||
- repetition shorthand/list/pyramid parsing;
|
||||
- direct v4 -> v5 database migration;
|
||||
- heterogeneous mobile-set import;
|
||||
- Notcurses input lifecycle translation: PRESS/REPEAT are actionable while a
|
||||
RELEASE event is consumed without creating a second navigation action.
|
||||
- targetless mobile SETS persistence;
|
||||
- mobile-import idempotence.
|
||||
- stable-ID mobile-to-desktop rename reconciliation without duplicate catalog
|
||||
rows or historical-reference replacement.
|
||||
|
||||
## 5. Build
|
||||
|
||||
|
|
@ -130,6 +142,14 @@ Install to the connected device when hardware behavior changes:
|
|||
adb install -r app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
Android repository host tests additionally cover exercise editing:
|
||||
|
||||
- trimmed rename preserves `exercise_id` and recalculates `normalized_name`;
|
||||
- duplicate and invalid names are rejected;
|
||||
- completed history and active-draft references resolve the renamed catalog row;
|
||||
- a referenced profile change is explicitly rejected;
|
||||
- same-ID PC-catalog rename reconciles in place without a duplicate.
|
||||
|
||||
## 7. Hardware MTP validation
|
||||
|
||||
Hardware probes and real synchronization are separate from the normal automated
|
||||
|
|
@ -238,7 +258,7 @@ Coverage proves:
|
|||
Current normal baseline:
|
||||
|
||||
```text
|
||||
21/21 PASS
|
||||
22/22 PASS
|
||||
```
|
||||
|
||||
## 12. Body analytics regression
|
||||
|
|
@ -263,5 +283,53 @@ Coverage includes:
|
|||
Current normal baseline:
|
||||
|
||||
```text
|
||||
21/21 PASS
|
||||
22/22 PASS
|
||||
```
|
||||
|
||||
## 13. Android session draft v1
|
||||
|
||||
Android schema v4 adds one durable active draft with an explicit additive v3 ->
|
||||
v4 migration. The current host suite has **8 tests**, covering all exercise
|
||||
shapes and raw partial text, fresh repository restore, remove/discard, atomic
|
||||
finalization and repeated-finalize rejection, rollback, catalog reconciliation,
|
||||
missing-selection recovery, explicit DB-open failure and historical migration.
|
||||
|
||||
```bash
|
||||
cd android
|
||||
JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew test
|
||||
JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew assembleDebug assembleDebugAndroidTest
|
||||
adb install -r app/build/outputs/apk/debug/app-debug.apk
|
||||
adb install -r app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk
|
||||
adb shell am instrument -w -e package com.labfytools.trainlog \
|
||||
com.labfytools.trainlog.test/androidx.test.runner.AndroidJUnitRunner
|
||||
```
|
||||
|
||||
The device instrumentation suite has **5 tests**: two real-SQLite repository
|
||||
checks and three production-screen Compose UI checks. Coverage includes exact
|
||||
raw form restoration through Activity recreation, cancellation and confirmation
|
||||
of discard, final save with no stale Resume after recreation, and refusal of a
|
||||
second completion. All tests use isolated databases; the debug-only,
|
||||
non-exported Activity renders production Home/Session screens without exporting
|
||||
synthetic data. The phone must be unlocked and interactive. Do not interpret a
|
||||
locked-screen `No compose hierarchies found` failure as a passing UI check.
|
||||
|
||||
The Samsung SM_G990B additionally passed the normal `MainActivity` matrix:
|
||||
|
||||
- two real catalog exercises with distinct continuous values survived Home,
|
||||
another app, background `am kill` with verified PID exit, and force-stop;
|
||||
- rotation recreated the Activity and Home Resume restored both exercises;
|
||||
- removal of one exercise survived process death, with the other intact;
|
||||
- raw duration/speed text survived process death and force-stop exactly;
|
||||
- Back and the normal new-session action preserved the existing draft;
|
||||
- discard cancellation preserved the DB exactly; confirmation and relaunch
|
||||
left all draft tables empty and no Resume action;
|
||||
- completed History and mobile export excluded the populated draft;
|
||||
- every original domain row survived migration and the entire device matrix.
|
||||
|
||||
Final-save UI and repeated-finalization tests ran on-device with isolated data;
|
||||
no fictitious completed session was added to the user's history. Real migration
|
||||
and final state passed SQLite integrity/foreign-key checks. The pre-upgrade DB
|
||||
and preferences backup is outside the repository. No uninstall, package-data
|
||||
clear, desktop schema change or frozen artifact change is part of this repair.
|
||||
|
||||
Detailed retained evidence: [Android draft execution record](reviews/android_session_draft_v1_resume.md).
|
||||
|
|
|
|||
18
docs/tui.md
18
docs/tui.md
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## 1. Purpose
|
||||
|
||||
The Trainlog desktop application is a C17/ncursesw interface for durable
|
||||
The Trainlog desktop application is a C17/Notcurses interface for durable
|
||||
history, correction, analysis, visualization, direct data entry, and manual
|
||||
synchronization.
|
||||
|
||||
|
|
@ -46,6 +46,22 @@ Smaller terminals display a clear fallback instead of corrupt layout.
|
|||
|
||||
The TUI uses centralized semantic theme roles.
|
||||
|
||||
Notcurses provides a true-color Catppuccin-derived dark palette: background
|
||||
`#1E1E2E`, surface `#181825`, text `#CDD6F4`, and semantic accent, success,
|
||||
warning, error, muted, and graph roles. Unicode frames and visible selection
|
||||
markers enhance presentation without becoming application semantics.
|
||||
|
||||
The backend owns one standard plane for a run, translates terminal input into
|
||||
Trainlog-owned keys, accepts complete UTF-8 code points in prompts, and
|
||||
re-queries dimensions while rendering so the 72x20 minimum/fallback recovers
|
||||
after a resize.
|
||||
|
||||
Input lifecycle is handled at that boundary: legacy/unknown terminal events,
|
||||
Notcurses PRESS events, and deliberate auto-REPEAT events become one logical
|
||||
Trainlog action; Notcurses RELEASE events are consumed and never reach screen
|
||||
navigation or prompt handling. This prevents extended terminal keyboard
|
||||
protocols from applying one physical keypress twice.
|
||||
|
||||
Color is not the sole state carrier.
|
||||
|
||||
Typical roles:
|
||||
|
|
|
|||
|
|
@ -265,6 +265,19 @@ def main() -> int:
|
|||
+ second
|
||||
)
|
||||
|
||||
renamed_payload = payload()
|
||||
renamed_payload["exercises"][0]["name"] = "Pompes corrigées"
|
||||
renamed_payload["sessions"][0]["exercises"][0]["name"] = "Pompes corrigées"
|
||||
json_path.write_text(
|
||||
json.dumps(renamed_payload, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
renamed = run_import(json_path, database_path)
|
||||
if "exercises_reconciled=1" not in renamed:
|
||||
raise AssertionError(
|
||||
"stable-ID rename was not reconciled:\n" + renamed
|
||||
)
|
||||
|
||||
connection = sqlite3.connect(
|
||||
database_path
|
||||
)
|
||||
|
|
@ -317,6 +330,23 @@ def main() -> int:
|
|||
raise AssertionError(
|
||||
f"fake target persisted: {target!r}"
|
||||
)
|
||||
|
||||
catalog = connection.execute(
|
||||
"""
|
||||
SELECT exercise_id, name, normalized_name
|
||||
FROM exercises;
|
||||
"""
|
||||
).fetchall()
|
||||
if catalog != [
|
||||
(
|
||||
"ex_mobile_pyramid",
|
||||
"Pompes corrigées",
|
||||
"pompes corrigées",
|
||||
)
|
||||
]:
|
||||
raise AssertionError(
|
||||
f"stable-ID rename created or lost catalog row: {catalog!r}"
|
||||
)
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
|
|
|||
|
|
@ -736,11 +736,45 @@ def import_exercises(
|
|||
f"profil incompatible pour {exercise_id}"
|
||||
)
|
||||
|
||||
by_name = lookup_exercise_by_normalized(
|
||||
connection,
|
||||
normalized,
|
||||
)
|
||||
if (
|
||||
by_name is not None
|
||||
and by_name["id"] != by_id["id"]
|
||||
):
|
||||
raise ImportFailure(
|
||||
"conflit de nom pour l'identité "
|
||||
+ exercise_id
|
||||
)
|
||||
|
||||
# CONTRACT: exercise_id is the synchronization identity. A rename
|
||||
# updates metadata in place, retaining every historical and draft
|
||||
# foreign-key reference instead of creating a second exercise.
|
||||
if (
|
||||
by_id["name"] != exercise["name"]
|
||||
or by_id["normalized_name"] != normalized
|
||||
):
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE exercises
|
||||
SET name = ?, normalized_name = ?
|
||||
WHERE id = ?;
|
||||
""",
|
||||
(
|
||||
exercise["name"],
|
||||
normalized,
|
||||
by_id["id"],
|
||||
),
|
||||
)
|
||||
report["exercises_reconciled"] += 1
|
||||
else:
|
||||
report["exercises_skipped"] += 1
|
||||
|
||||
mapping[exercise_id] = (
|
||||
by_id["exercise_id"]
|
||||
)
|
||||
|
||||
report["exercises_skipped"] += 1
|
||||
continue
|
||||
|
||||
by_name = lookup_exercise_by_normalized(
|
||||
|
|
|
|||
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
|
||||
#define TRAINLOG_THEME_H
|
||||
|
||||
#include <curses.h>
|
||||
#include <stdint.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 {
|
||||
|
|
@ -18,7 +18,16 @@ typedef enum TrainlogColorRole {
|
|||
TRAINLOG_COLOR_GRAPH = 6
|
||||
} TrainlogColorRole;
|
||||
|
||||
void trainlog_theme_initialize(void);
|
||||
attr_t trainlog_theme_attribute(TrainlogColorRole role);
|
||||
/*
|
||||
* CONTRACT: styles are terminal-library-independent semantic values. Screen
|
||||
* code never owns a palette index or an ncurses attribute.
|
||||
*/
|
||||
typedef uint32_t TrainlogTextStyle;
|
||||
|
||||
#define TRAINLOG_TEXT_NORMAL ((TrainlogTextStyle)0U)
|
||||
#define TRAINLOG_TEXT_BOLD ((TrainlogTextStyle)0x0001U)
|
||||
#define TRAINLOG_TEXT_REVERSE ((TrainlogTextStyle)0x0002U)
|
||||
|
||||
TrainlogTextStyle trainlog_theme_style(TrainlogColorRole role);
|
||||
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
/**
|
||||
* @file tui.h
|
||||
* @brief Interactive ncurses entry point.
|
||||
* @brief Interactive Notcurses entry point.
|
||||
*/
|
||||
|
||||
#include "trainlog/database.h"
|
||||
|
|
|
|||
|
|
@ -5,10 +5,7 @@ uuid_dep = dependency('uuid', required: true)
|
|||
udev_dep = dependency('libudev', required: true)
|
||||
mtp_dep = dependency('libmtp', required: true)
|
||||
|
||||
ncursesw_dep = dependency('ncursesw', required: false)
|
||||
if not ncursesw_dep.found()
|
||||
ncursesw_dep = cc.find_library('ncursesw', required: true)
|
||||
endif
|
||||
notcurses_dep = dependency('notcurses-core', required: true)
|
||||
|
||||
utf8proc_dep = dependency('libutf8proc', required: false)
|
||||
if not utf8proc_dep.found()
|
||||
|
|
@ -71,6 +68,7 @@ trainlog_core_dep = declare_dependency(
|
|||
|
||||
trainlog_tui_sources = files(
|
||||
'src/main.c',
|
||||
'src/terminal.c',
|
||||
'src/theme.c',
|
||||
'src/tui.c',
|
||||
)
|
||||
|
|
@ -80,7 +78,7 @@ trainlog_exe = executable(
|
|||
trainlog_tui_sources,
|
||||
dependencies: [
|
||||
trainlog_core_dep,
|
||||
ncursesw_dep,
|
||||
notcurses_dep,
|
||||
],
|
||||
c_args: strict_c_args,
|
||||
install: true,
|
||||
|
|
@ -367,3 +365,17 @@ test(
|
|||
'body_analytics',
|
||||
test_body_analytics,
|
||||
)
|
||||
|
||||
test_terminal_input = executable(
|
||||
'test_terminal_input',
|
||||
'tests/test_terminal_input.c',
|
||||
'src/terminal.c',
|
||||
include_directories: trainlog_include,
|
||||
dependencies: notcurses_dep,
|
||||
c_args: strict_c_args,
|
||||
)
|
||||
|
||||
test(
|
||||
'terminal_input_event_type_policy',
|
||||
test_terminal_input,
|
||||
)
|
||||
|
|
|
|||
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
|
||||
* @brief Centralized Trainlog ncurses colors.
|
||||
* @brief Centralized Trainlog semantic text styles.
|
||||
*/
|
||||
|
||||
#include "trainlog/theme.h"
|
||||
|
||||
#include <curses.h>
|
||||
|
||||
void trainlog_theme_initialize(void)
|
||||
TrainlogTextStyle trainlog_theme_style(TrainlogColorRole role)
|
||||
{
|
||||
if (!has_colors()) {
|
||||
return;
|
||||
}
|
||||
|
||||
start_color();
|
||||
use_default_colors();
|
||||
|
||||
init_pair(TRAINLOG_COLOR_ACCENT, COLOR_CYAN, -1);
|
||||
init_pair(TRAINLOG_COLOR_SUCCESS, COLOR_GREEN, -1);
|
||||
init_pair(TRAINLOG_COLOR_WARNING, COLOR_YELLOW, -1);
|
||||
init_pair(TRAINLOG_COLOR_ERROR, COLOR_RED, -1);
|
||||
init_pair(TRAINLOG_COLOR_MUTED, COLOR_BLUE, -1);
|
||||
init_pair(TRAINLOG_COLOR_GRAPH, COLOR_MAGENTA, -1);
|
||||
}
|
||||
|
||||
attr_t trainlog_theme_attribute(TrainlogColorRole role)
|
||||
{
|
||||
if (!has_colors() || role == TRAINLOG_COLOR_DEFAULT) {
|
||||
return A_NORMAL;
|
||||
}
|
||||
|
||||
return COLOR_PAIR((short)role);
|
||||
return ((TrainlogTextStyle)role << 8U);
|
||||
}
|
||||
|
|
|
|||
2135
tui/src/tui.c
2135
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