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