diff --git a/iosApp/VitruvianPhoenix/VitruvianPhoenix/Info.plist b/iosApp/VitruvianPhoenix/VitruvianPhoenix/Info.plist
index 525324014..efc355c75 100644
--- a/iosApp/VitruvianPhoenix/VitruvianPhoenix/Info.plist
+++ b/iosApp/VitruvianPhoenix/VitruvianPhoenix/Info.plist
@@ -28,6 +28,15 @@
CFBundleDisplayName
Project Phoenix
+ CFBundleLocalizations
+
+ en
+ de
+ es
+ fr
+ nl
+ it
+
NSBluetoothAlwaysUsageDescription
Project Phoenix needs Bluetooth to connect to your Vitruvian Trainer machine.
NSBluetoothPeripheralUsageDescription
diff --git a/shared/src/androidMain/kotlin/com/devil/phoenixproject/domain/model/CurrentLanguage.android.kt b/shared/src/androidMain/kotlin/com/devil/phoenixproject/domain/model/CurrentLanguage.android.kt
new file mode 100644
index 000000000..b4c90b94a
--- /dev/null
+++ b/shared/src/androidMain/kotlin/com/devil/phoenixproject/domain/model/CurrentLanguage.android.kt
@@ -0,0 +1,11 @@
+package com.devil.phoenixproject.domain.model
+
+import java.util.Locale
+
+/**
+ * Android actual for [currentLanguageCode]. Returns the JVM `Locale`'s
+ * lowercased language code, e.g. `"en"` / `"it"`. Returns `""` if the
+ * default locale is `ROOT` or otherwise has no language component.
+ */
+actual fun currentLanguageCode(): String =
+ Locale.getDefault().language.orEmpty()
diff --git a/shared/src/commonMain/composeResources/values-it/strings.xml b/shared/src/commonMain/composeResources/values-it/strings.xml
new file mode 100644
index 000000000..895240019
--- /dev/null
+++ b/shared/src/commonMain/composeResources/values-it/strings.xml
@@ -0,0 +1,33 @@
+
+
+
+
+ Phoenix
+
+
+ Livello Echo
+ Difficile
+ Più difficile
+ Difficilissimo
+ Epico
+ Carico eccentrico
+ Carico durante la fase eccentrica (discesa)
+ Tempismo conteggio ripetizioni
+ Conta in cima (picco concentrico)
+ Conta in basso (fase eccentrica)
+
+
+ CARICO ECCENTRICO
+ LIVELLO ECHO
+
+
+ LIVELLO ECHO
+
diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml
index 5279edf00..3b87aaeec 100644
--- a/shared/src/commonMain/composeResources/values/strings.xml
+++ b/shared/src/commonMain/composeResources/values/strings.xml
@@ -273,7 +273,15 @@
Echo Mode
Echo Mode Configuration
Echo Level
+ Hard
+ Harder
+ Hardest
+ Epic
Eccentric Load
+ Load during eccentric (lowering) phase
+ Rep Count Timing
+ Count at top of lift (concentric peak)
+ Count at bottom (eccentric valley)
Select TUT Variant
diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/CurrentLanguage.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/CurrentLanguage.kt
new file mode 100644
index 000000000..198c92151
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/CurrentLanguage.kt
@@ -0,0 +1,21 @@
+package com.devil.phoenixproject.domain.model
+
+/**
+ * Returns the BCP-47 language subtag of the active app locale, lowercased
+ * (e.g. `"en"`, `"it"`, `"de"`). Returns `""` when the language cannot be
+ * determined.
+ *
+ * This is a multiplatform-friendly alternative to reading
+ * `LocalConfiguration.current.locales`, which is not exposed in the
+ * Compose Multiplatform iOS klib (CMP 1.10.3) and would not compile for
+ * `iosArm64`. We delegate to platform-native APIs:
+ *
+ * - Android: `java.util.Locale.getDefault().language`
+ * - iOS: `NSUserDefaults.standardUserDefaults` first entry of `AppleLanguages`
+ * (the user-selected preferred language list, which is exactly the
+ * Italian iPhone setting from issue #540).
+ *
+ * The returned value is the *language* subtag only — no region, no script.
+ * Tests for the Italian NBSP branch check `equals("it", ignoreCase = true)`.
+ */
+expect fun currentLanguageCode(): String
diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/EccentricLoadLabels.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/EccentricLoadLabels.kt
new file mode 100644
index 000000000..e2bff4b19
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/EccentricLoadLabels.kt
@@ -0,0 +1,86 @@
+package com.devil.phoenixproject.domain.model
+
+import androidx.compose.runtime.Composable
+import org.jetbrains.compose.resources.stringResource
+import vitruvianprojectphoenix.shared.generated.resources.Res
+import vitruvianprojectphoenix.shared.generated.resources.echo_level_epic
+import vitruvianprojectphoenix.shared.generated.resources.echo_level_hard
+import vitruvianprojectphoenix.shared.generated.resources.echo_level_harder
+import vitruvianprojectphoenix.shared.generated.resources.echo_level_hardest
+
+/**
+ * Locale-aware label helpers for the Echo-mode UI block in
+ * [com.devil.phoenixproject.presentation.screen.JustLiftScreen].
+ *
+ * Background (issue #540): on Italian iPhone the system font (SF Pro) renders
+ * the literal sequence `110%` with effectively zero left-advance on the `%`
+ * glyph, so the EccentricLoad dropdown value reads as `11U%` / `11 U%`.
+ * Italian typography inserts a hard non-breaking space (U+00A0) between the
+ * digits and the percent sign — `110 %` — and iOS SF Pro renders that form
+ * without a glyph collision. This file emits that form for any `it-*`
+ * language and the back-compat ASCII `110%` form for every other locale.
+ *
+ * The pure formatter [formatEccentricLoad] is `internal` so the unit test in
+ * `commonTest` can exercise the per-locale behaviour without needing a
+ * Compose runtime or a platform-specific locale implementation. The
+ * `@Composable` [eccentricLoadLabel] wrapper uses the expect/actual
+ * [currentLanguageCode] helper to get the active app locale without pulling
+ * in any Compose-Multiplatform-specific locale API.
+ */
+
+/**
+ * Non-Composable pure formatter used by the `@Composable` [eccentricLoadLabel]
+ * wrapper. Exposed as `internal` for unit testing.
+ *
+ * Behaviour:
+ * * For Italian (the language code `it`, case-insensitive) this returns the
+ * digits followed by U+00A0 NBSP and `%` — e.g. `"110\u00A0%"`. This is
+ * the canonical Italian typographic form and resolves the iOS SF Pro
+ * `%`-glyph collision.
+ * * For every other locale this returns the digits followed by `%` — e.g.
+ * `"110%"` (byte-identical to the legacy `EccentricLoad.displayName` so
+ * the existing English UI is unchanged).
+ *
+ * @param load the [EccentricLoad] enum entry whose
+ * [EccentricLoad.percentage] is formatted.
+ * @param language the lowercased language code of the active locale, e.g.
+ * `"en"`, `"it"`, `"de"`. Pass `""` or a non-`"it"` value to
+ * get the ASCII form.
+ */
+internal fun formatEccentricLoad(load: EccentricLoad, language: String): String {
+ return if (language.equals("it", ignoreCase = true)) {
+ "${load.percentage}\u00A0%"
+ } else {
+ "${load.percentage}%"
+ }
+}
+
+/**
+ * Composable wrapper that resolves the active language code via
+ * [currentLanguageCode] and delegates to [formatEccentricLoad]. No
+ * Compose-Multiplatform-specific locale API is required, so this helper
+ * compiles cleanly for both the Android and iOS targets.
+ *
+ * Returns the locale-formatted percentage for the dropdown value cell in
+ * [com.devil.phoenixproject.presentation.screen.JustLiftScreen] (line ~528)
+ * and the matching dropdown item text (line ~544).
+ */
+@Composable
+fun eccentricLoadLabel(load: EccentricLoad): String {
+ return formatEccentricLoad(load, currentLanguageCode())
+}
+
+/**
+ * Returns the [EchoLevel] label via the [stringResource] lookup so the
+ * FilterChip and SegmentedButton rows pick up the per-locale translation.
+ *
+ * Resource key convention: `echo_level_` → `echo_level_hard`,
+ * `echo_level_harder`, `echo_level_hardest`, `echo_level_epic`.
+ */
+@Composable
+fun echoLevelLabel(level: EchoLevel): String = when (level) {
+ EchoLevel.HARD -> stringResource(Res.string.echo_level_hard)
+ EchoLevel.HARDER -> stringResource(Res.string.echo_level_harder)
+ EchoLevel.HARDEST -> stringResource(Res.string.echo_level_hardest)
+ EchoLevel.EPIC -> stringResource(Res.string.echo_level_epic)
+}
diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/JustLiftScreen.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/JustLiftScreen.kt
index a169ba5fa..252b71f48 100644
--- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/JustLiftScreen.kt
+++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/JustLiftScreen.kt
@@ -94,6 +94,8 @@ import com.devil.phoenixproject.domain.model.WeightUnit
import com.devil.phoenixproject.domain.model.WorkoutMetric
import com.devil.phoenixproject.domain.model.WorkoutMode
import com.devil.phoenixproject.domain.model.WorkoutState
+import com.devil.phoenixproject.domain.model.eccentricLoadLabel
+import com.devil.phoenixproject.domain.model.echoLevelLabel
import com.devil.phoenixproject.domain.model.toWorkoutMode
import com.devil.phoenixproject.presentation.components.AddProfileDialog
import com.devil.phoenixproject.presentation.components.CompactNumberPicker
@@ -110,7 +112,13 @@ import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject
import vitruvianprojectphoenix.shared.generated.resources.Res
import vitruvianprojectphoenix.shared.generated.resources.cd_close_workout
+import vitruvianprojectphoenix.shared.generated.resources.eccentric_load
+import vitruvianprojectphoenix.shared.generated.resources.eccentric_load_helper
+import vitruvianprojectphoenix.shared.generated.resources.echo_level
import vitruvianprojectphoenix.shared.generated.resources.label_live
+import vitruvianprojectphoenix.shared.generated.resources.rep_count_timing
+import vitruvianprojectphoenix.shared.generated.resources.rep_count_timing_bottom
+import vitruvianprojectphoenix.shared.generated.resources.rep_count_timing_top
/**
* Just Lift screen - quick workout configuration.
@@ -512,7 +520,7 @@ fun JustLiftScreen(navController: NavController, viewModel: MainViewModel, theme
},
) {
Text(
- "Eccentric Load",
+ stringResource(Res.string.eccentric_load),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
@@ -525,7 +533,7 @@ fun JustLiftScreen(navController: NavController, viewModel: MainViewModel, theme
onExpandedChange = { expanded = it },
) {
OutlinedTextField(
- value = eccentricLoad.displayName,
+ value = eccentricLoadLabel(eccentricLoad),
onValueChange = {},
readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
@@ -541,7 +549,7 @@ fun JustLiftScreen(navController: NavController, viewModel: MainViewModel, theme
) {
EccentricLoad.entries.forEach { load ->
DropdownMenuItem(
- text = { Text(load.displayName) },
+ text = { Text(eccentricLoadLabel(load)) },
onClick = {
eccentricLoad = load
expanded = false
@@ -553,7 +561,7 @@ fun JustLiftScreen(navController: NavController, viewModel: MainViewModel, theme
}
Text(
- "Load during eccentric (lowering) phase",
+ stringResource(Res.string.eccentric_load_helper),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -581,7 +589,7 @@ fun JustLiftScreen(navController: NavController, viewModel: MainViewModel, theme
},
) {
Text(
- "Echo Level",
+ stringResource(Res.string.echo_level),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
@@ -600,7 +608,7 @@ fun JustLiftScreen(navController: NavController, viewModel: MainViewModel, theme
echoLevel = level
selectedMode = WorkoutMode.Echo(level)
},
- label = { Text(level.displayName) },
+ label = { Text(echoLevelLabel(level)) },
)
}
}
@@ -617,7 +625,7 @@ fun JustLiftScreen(navController: NavController, viewModel: MainViewModel, theme
},
selected = echoLevel == level,
) {
- Text(level.displayName, maxLines = 1)
+ Text(echoLevelLabel(level), maxLines = 1)
}
}
}
@@ -642,16 +650,16 @@ fun JustLiftScreen(navController: NavController, viewModel: MainViewModel, theme
) {
Column(modifier = Modifier.weight(1f)) {
Text(
- text = "Rep Count Timing",
+ text = stringResource(Res.string.rep_count_timing),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = if (repCountTiming == RepCountTiming.TOP) {
- "Count at top of lift (concentric peak)"
+ stringResource(Res.string.rep_count_timing_top)
} else {
- "Count at bottom (eccentric valley)"
+ stringResource(Res.string.rep_count_timing_bottom)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/model/EccentricLoadDisplayNameTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/model/EccentricLoadDisplayNameTest.kt
new file mode 100644
index 000000000..50b3261a2
--- /dev/null
+++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/model/EccentricLoadDisplayNameTest.kt
@@ -0,0 +1,156 @@
+package com.devil.phoenixproject.domain.model
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+/**
+ * Regression + recreation-evidence test for issue #540:
+ * "iOS Italian locale: eccentric percentage UI is clipped in Just Lift".
+ *
+ * Background
+ * ----------
+ * On Italian iPhone (app 0.9.1), the Just Lift Echo-mode block rendered fully
+ * in English and the Eccentric Load dropdown value `110%` had its `%` glyph
+ * visually collide with the trailing `0` in iOS SF Pro at bodyLarge, making
+ * the value read as `11 U%` / `11U%`. Three layered defects:
+ *
+ * 1. JustLiftScreen.kt:515,528,544 used hard-coded English Kotlin literals
+ * (`"Eccentric Load"`, `"Echo Level"`, `"Rep Count Timing"`, etc.) and
+ * bound the raw `EccentricLoad.displayName` to the dropdown value with
+ * no Locale-aware formatter.
+ * 2. `EccentricLoad.displayName` is `"%"` with no separator; iOS SF
+ * Pro drops the `%` glyph with effectively zero left-advance, causing
+ * the trailing-0 / % collision.
+ * 3. composeResources had values-{de,es,fr,nl} but no values-it directory,
+ * and iosApp Info.plist did not declare `CFBundleLocalizations`, so
+ * Italian could not be served even if the literals were converted.
+ *
+ * The bugfix:
+ * - Replaces the hard-coded English literals with `stringResource` lookups.
+ * - Routes the dropdown value through a new `formatEccentricLoad(load,
+ * language)` helper that emits `"110\u00A0%"` (with U+00A0 NBSP) for any
+ * `it-*` language and `"110%"` for every other locale.
+ * - Adds `values-it/strings.xml` plus a `CFBundleLocalizations` entry in the
+ * iOS Info.plist so the system actually advertises Italian as a shipped
+ * locale.
+ *
+ * These assertions pin:
+ * - The unchanged `EccentricLoad` / `EchoLevel` / `WorkoutMode.Echo` enum
+ * `displayName` values (BLE, tests, CSV consumers rely on the raw form).
+ * - The new locale-aware formatter's behaviour for `en` (ASCII) and `it`
+ * (NBSP), including a region-subtag variant (`it-IT`) so we know the
+ * substring check is robust to BCP-47 tags.
+ * - A per-entry cross-locale invariant (every `EccentricLoad` value
+ * formats correctly under both `en` and `it`).
+ */
+class EccentricLoadDisplayNameTest {
+
+ // -------- Pre-fix invariants: enum displayName contract (unchanged) --------
+
+ @Test
+ fun everyEccentricLoadDisplayNameIsPercentSuffixedWithNoSeparator() {
+ EccentricLoad.entries.forEach { load ->
+ assertTrue(
+ load.displayName.endsWith("%"),
+ "EccentricLoad.${load.name}.displayName should end with '%' but was '${load.displayName}'",
+ )
+ // NBSP must NOT appear in the wire format — that would corrupt
+ // BLE / CSV consumers.
+ assertFalse(
+ load.displayName.contains('\u00A0'),
+ "EccentricLoad.${load.name}.displayName must not contain NBSP (wire format) but was '${load.displayName}'",
+ )
+ }
+ }
+
+ @Test
+ fun load110DisplayNameIsExactly110PercentWithAsciiPercentGlyph() {
+ assertEquals("110%", EccentricLoad.LOAD_110.displayName)
+ assertEquals(110, EccentricLoad.LOAD_110.percentage)
+ }
+
+ @Test
+ fun echoLevelDisplayNameValuesAreHardCodedEnglish() {
+ assertEquals("Hard", EchoLevel.HARD.displayName)
+ assertEquals("Harder", EchoLevel.HARDER.displayName)
+ assertEquals("Hardest", EchoLevel.HARDEST.displayName)
+ assertEquals("Epic", EchoLevel.EPIC.displayName)
+ }
+
+ @Test
+ fun workoutModeEchoDisplayNameIsHardCodedEnglish() {
+ assertEquals("Echo", WorkoutMode.Echo(EchoLevel.HARD).displayName)
+ }
+
+ // -------- Post-fix: locale-aware formatter (issue #540) --------
+
+ @Test
+ fun formatEccentricLoadEnglishKeepsAsciiPercentNoSeparator() {
+ EccentricLoad.entries.forEach { load ->
+ val label = formatEccentricLoad(load, "en")
+ assertEquals("${load.percentage}%", label, "en locale should emit ASCII form for ${load.name}")
+ assertFalse(label.contains('\u00A0'), "en must not introduce NBSP")
+ }
+ }
+
+ @Test
+ fun formatEccentricLoadItalianInsertsNbspBeforePercentGlyph() {
+ val label = formatEccentricLoad(EccentricLoad.LOAD_110, "it")
+ // Italian typography uses NBSP (U+00A0) between number and "%" so the
+ // SF Pro "%" glyph no longer collides with the trailing "0" on
+ // Italian iPhone at bodyLarge.
+ assertEquals("110\u00A0%", label)
+ assertTrue(label.contains('\u00A0'), "it must contain NBSP (U+00A0)")
+ assertTrue(label.endsWith("%"), "percent glyph still required")
+ }
+
+ @Test
+ fun currentLanguageCodeIosExtractsLanguageSubtag() {
+ // The iOS actual for `currentLanguageCode()` must reduce a BCP-47
+ // AppleLanguages entry like "it-IT" / "en-US" to the language subtag
+ // so the formatter's `equals("it", ignoreCase = true)` branch fires
+ // regardless of region. We can't directly test the iOS actual from
+ // androidHostTest (it's an iosMain source set), but we can pin the
+ // invariant by exercising the formatter with the already-extracted
+ // short code: passing the full tag would fail the equals check, so
+ // the iOS actual MUST strip the region before the call.
+ val full = "it-IT"
+ val extracted = full.substringBefore('-').lowercase()
+ assertEquals("it", extracted)
+ // And the formatter must accept the extracted form.
+ val label = formatEccentricLoad(EccentricLoad.LOAD_110, extracted)
+ assertEquals("110\u00A0%", label)
+ }
+
+ @Test
+ fun formatEccentricLoadEmptyLanguageFallsBackToAscii() {
+ // Defensive: empty / unknown language code must not crash and must
+ // emit the safe ASCII form (no NBSP).
+ val label = formatEccentricLoad(EccentricLoad.LOAD_110, "")
+ assertEquals("110%", label)
+ }
+
+ @Test
+ fun formatEccentricLoadItCaseInsensitive() {
+ // The helper uses equals("it", ignoreCase = true); verify the
+ // uppercase / mixed-case form still routes to the Italian branch.
+ val label = formatEccentricLoad(EccentricLoad.LOAD_100, "IT")
+ assertEquals("100\u00A0%", label)
+ }
+
+ @Test
+ fun everyEccentricLoadCrossLocaleInvariant() {
+ // Per-entry cross-locale check: for every EccentricLoad value the
+ // en and it outputs differ only in the separator (NBSP vs none) and
+ // both end in '%'.
+ EccentricLoad.entries.forEach { load ->
+ val en = formatEccentricLoad(load, "en")
+ val it = formatEccentricLoad(load, "it")
+ assertEquals("${load.percentage}%", en, "en form of ${load.name}")
+ assertEquals("${load.percentage}\u00A0%", it, "it form of ${load.name}")
+ assertTrue(en.endsWith("%") && it.endsWith("%"))
+ }
+ }
+}
diff --git a/shared/src/iosMain/kotlin/com/devil/phoenixproject/domain/model/CurrentLanguage.ios.kt b/shared/src/iosMain/kotlin/com/devil/phoenixproject/domain/model/CurrentLanguage.ios.kt
new file mode 100644
index 000000000..24c80a7d6
--- /dev/null
+++ b/shared/src/iosMain/kotlin/com/devil/phoenixproject/domain/model/CurrentLanguage.ios.kt
@@ -0,0 +1,25 @@
+package com.devil.phoenixproject.domain.model
+
+import platform.Foundation.NSUserDefaults
+
+/**
+ * iOS actual for [currentLanguageCode]. Returns the language subtag of the
+ * first entry of the `AppleLanguages` array in `NSUserDefaults` — this is
+ * the user-selected preferred language set via Settings → General →
+ * Language & Region, which is exactly the value the bug report's Italian
+ * iPhone uses.
+ *
+ * Returns `""` when the array is empty or its first entry is not a string
+ * (e.g. before the app has read user defaults).
+ *
+ * `AppleLanguages` values are BCP-47 tags like `"en-US"` / `"it-IT"`; we
+ * only need the language subtag for the percent-format decision.
+ */
+actual fun currentLanguageCode(): String {
+ val defaults = NSUserDefaults.standardUserDefaults
+ val languages: Any? = defaults.objectForKey("AppleLanguages")
+ @Suppress("UNCHECKED_CAST")
+ val list = languages as? List
+ val first = list?.firstOrNull().orEmpty()
+ return first.substringBefore('-').lowercase()
+}