From ea595ba7bc61094363f2d376acb043297638ec81 Mon Sep 17 00:00:00 2001 From: Moustafa Attia <72319345+attmous@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:01:22 +0200 Subject: [PATCH 1/4] refactor(profile): localize presentation copy --- config/detekt-baseline.xml | 14 +- feature/profile/build.gradle.kts | 5 +- .../musfit/ui/profile/AiCoachVariantCopy.kt | 4 - .../src/internal/res/values/strings.xml | 4 + .../musfit/ui/profile/AiCoachVariantCopy.kt | 3 - .../com/musfit/ui/profile/AccountErasureUi.kt | 38 +- .../java/com/musfit/ui/profile/AccountUi.kt | 45 +- .../musfit/ui/profile/AiCoachSettingsUi.kt | 148 +++-- .../com/musfit/ui/profile/GoogleSignIn.kt | 13 +- .../ui/profile/HealthConnectSettingsUi.kt | 86 ++- .../musfit/ui/profile/ProfileEditContent.kt | 208 +++--- .../musfit/ui/profile/ProfileExpressiveUi.kt | 9 +- .../com/musfit/ui/profile/ProfileHistoryUi.kt | 122 ++-- .../com/musfit/ui/profile/ProfileScreen.kt | 235 ++++--- .../ui/profile/ProfileSettingsScreen.kt | 167 +++-- .../ui/profile/ProfileSettingsViewModel.kt | 620 ++++++++++-------- .../com/musfit/ui/profile/ProfileUiText.kt | 24 + .../com/musfit/ui/profile/ProfileViewModel.kt | 72 +- .../profile/src/main/res/values/strings.xml | 352 ++++++++++ .../musfit/ui/profile/AiCoachVariantCopy.kt | 3 - .../com/musfit/ui/profile/AccountUiTest.kt | 11 + .../ui/profile/AiCoachVariantCopyTest.kt | 16 +- .../profile/ProfileSettingsViewModelTest.kt | 147 +++-- .../musfit/ui/profile/ProfileViewModelTest.kt | 56 +- .../ui/profile/ProfileComposeSemanticsTest.kt | 3 +- .../ProfileScreenshotRegressionTest.kt | 96 ++- .../profile-editor-foldable-german.png | Bin 0 -> 41727 bytes .../profile-editor-phone-pseudo-font-150.png | Bin 0 -> 46212 bytes .../profile-settings-phone-pseudo-rtl.png | Bin 0 -> 44998 bytes 29 files changed, 1682 insertions(+), 819 deletions(-) create mode 100644 feature/profile/src/internal/res/values/strings.xml create mode 100644 feature/profile/src/main/java/com/musfit/ui/profile/ProfileUiText.kt create mode 100644 feature/profile/src/main/res/values/strings.xml create mode 100644 feature/profile/src/test/java/com/musfit/ui/profile/AccountUiTest.kt create mode 100644 feature/profile/src/testInternalDebug/screenshots/profile-editor-foldable-german.png create mode 100644 feature/profile/src/testInternalDebug/screenshots/profile-editor-phone-pseudo-font-150.png create mode 100644 feature/profile/src/testInternalDebug/screenshots/profile-settings-phone-pseudo-rtl.png diff --git a/config/detekt-baseline.xml b/config/detekt-baseline.xml index c685df04..2a601d8a 100644 --- a/config/detekt-baseline.xml +++ b/config/detekt-baseline.xml @@ -40,12 +40,12 @@ LargeClass:FoodViewModel.kt$FoodViewModel : ViewModel LargeClass:TrainingRepository.kt$LocalTrainingRepository : TrainingRepository LargeClass:TrainingViewModel.kt$TrainingViewModel : ViewModel - LongMethod:AccountUi.kt$@Composable fun AccountEditSheet( name: String, email: String, error: String?, accent: TabAccent, onNameChange: (String) -> Unit, onEmailChange: (String) -> Unit, onDismiss: () -> Unit, onSave: () -> Unit, ) + LongMethod:AccountUi.kt$@Composable fun AccountEditSheet( name: String, email: String, error: UiText?, accent: TabAccent, onNameChange: (String) -> Unit, onEmailChange: (String) -> Unit, onDismiss: () -> Unit, onSave: () -> Unit, ) LongMethod:AddFoodScreen.kt$@Composable fun AddFoodScreen( state: FoodUiState, onBack: () -> Unit, onQueryChange: (String) -> Unit, onScanClick: () -> Unit, onTabSelected: (AddTab) -> Unit, onFoodClick: (String) -> Unit, onQuickTrack: () -> Unit, onAdjustGoals: () -> Unit, onCopyYesterday: () -> Unit, onSaveTemplate: () -> Unit, onScanLabel: () -> Unit, onProductNameChanged: (String) -> Unit, onBrandChanged: (String) -> Unit, onQuantityChanged: (String) -> Unit, onAmountServingChoiceSelected: (String) -> Unit, onCaloriesChanged: (String) -> Unit, onProteinChanged: (String) -> Unit, onCarbsChanged: (String) -> Unit, onFatChanged: (String) -> Unit, onSaveProduct: () -> Unit, onLogFood: () -> Unit, onCreateRecipe: () -> Unit, // Defaulted so the existing FoodScreen call site keeps compiling; the // orchestrator wires them (selectAddMode / meal retarget / openMealTemplates / // openRecipeBrowser / onKeepAddingFoodsChanged). onModeSelected: (FoodAddMode) -> Unit = {}, onMealRetarget: (String) -> Unit = {}, onOpenTemplates: () -> Unit = {}, onOpenRecipes: () -> Unit = {}, onKeepAddingChanged: (Boolean) -> Unit = {}, // logSavedFood's isSaving guard drops rapid-fire calls, so a plain loop only // logs the first item; a batching VM callback should replace the fallback. onLogAllYesterday: (() -> Unit)? = null, ) LongMethod:AddFoodScreen.kt$@Composable private fun AddFoodSearchPill( query: String, onQueryChange: (String) -> Unit, onScanClick: () -> Unit, ) LongMethod:AddFoodScreen.kt$@Composable private fun SameAsYesterdayCard( items: List<SavedFoodUiState>, mealLabel: String, actionVerb: String, onLogAll: () -> Unit, ) LongMethod:AiCoachChatRepository.kt$LocalAiCoachChatRepository$override suspend fun sendMessage(content: String, systemPrompt: String) - LongMethod:AiCoachSettingsUi.kt$@Composable fun AiCoachEditorSheet( provider: AiCoachProviderKind, baseUrl: String, modelName: String, localAgentKind: LocalAgentKind, apiKey: String, hasSavedApiKey: Boolean, error: String?, accent: TabAccent, onProviderChange: (AiCoachProviderKind) -> Unit, onBaseUrlChange: (String) -> Unit, onModelNameChange: (String) -> Unit, onLocalAgentKindChange: (LocalAgentKind) -> Unit, onApiKeyChange: (String) -> Unit, onDismiss: () -> Unit, onSave: () -> Unit, ) + LongMethod:AiCoachSettingsUi.kt$@Composable fun AiCoachEditorSheet( provider: AiCoachProviderKind, baseUrl: String, modelName: String, localAgentKind: LocalAgentKind, apiKey: String, hasSavedApiKey: Boolean, error: UiText?, accent: TabAccent, onProviderChange: (AiCoachProviderKind) -> Unit, onBaseUrlChange: (String) -> Unit, onModelNameChange: (String) -> Unit, onLocalAgentKindChange: (LocalAgentKind) -> Unit, onApiKeyChange: (String) -> Unit, onDismiss: () -> Unit, onSave: () -> Unit, ) LongMethod:AiCoachSettingsUi.kt$@Composable private fun EditorTextTile( label: String, value: String, onValueChange: (String) -> Unit, placeholder: String? = null, supporting: String? = null, masked: Boolean = false, keyboardType: KeyboardType = KeyboardType.Text, ) LongMethod:AppNavGraph.kt$@Composable fun AppNavGraph( barcodeScannerContent: @Composable ( onBarcodeDetected: (String) -> Unit, onClose: () -> Unit, ) -> Unit = { onBarcodeDetected, onClose -> BarcodeScannerScreen( onBarcodeDetected = onBarcodeDetected, onClose = onClose, ) }, ) LongMethod:BarcodeScannerScreen.kt$@OptIn(ExperimentalGetImage::class) @Composable fun BarcodeScannerScreen( onBarcodeDetected: (String) -> Unit, onClose: () -> Unit = {}, ) @@ -109,7 +109,7 @@ LongMethod:NutritionLabelScannerScreen.kt$@OptIn(ExperimentalGetImage::class) @Composable fun NutritionLabelScannerScreen( onLabelCaptured: (String) -> Unit, ) LongMethod:NutritionTrendsScreen.kt$@Composable private fun WeeklyMusFitScoreCard(score: FoodWeeklyScoreUiState) LongMethod:ProfileEditContent.kt$@Composable fun ProfileEditSheet( initial: UserProfile, initialWeightKg: Double?, targetApplyState: TargetApplyState = TargetApplyState.Idle, targetApplyTargets: RecommendedTargets? = null, onDismiss: () -> Unit, onSave: (UserProfile, Double?) -> Unit, onApplyTargets: ((RecommendedTargets) -> Unit)? = null, ) - LongMethod:ProfileExpressiveUi.kt$@Composable internal fun <T> ConnectedSegmentRow( options: List<T>, selected: T?, label: (T) -> String, accent: TabAccent, onSelect: (T) -> Unit, modifier: Modifier = Modifier, optionIcon: ((T) -> ImageVector?)? = null, equalWidths: Boolean = false, ) + LongMethod:ProfileExpressiveUi.kt$@Composable internal fun <T> ConnectedSegmentRow( options: List<T>, selected: T?, label: @Composable (T) -> String, accent: TabAccent, onSelect: (T) -> Unit, modifier: Modifier = Modifier, optionIcon: ((T) -> ImageVector?)? = null, equalWidths: Boolean = false, ) LongMethod:ProfileHistoryUi.kt$@Composable fun MeasurementHistoryScreen( title: String, entries: List<HistoryEntry>, // newest-first accent: TabAccent, onBack: () -> Unit, onAdd: () -> Unit, onEdit: (id: String, value: Double) -> Unit, onDelete: (id: String) -> Unit, modifier: Modifier = Modifier, goalValue: Double? = null, ) LongMethod:ProfileHistoryUi.kt$@Composable private fun HistoryChartCard( inRange: List<HistoryEntry>, range: HistoryRange, unit: String, accent: TabAccent, goalValue: Double?, ) LongMethod:ProfileHistoryUi.kt$@Composable private fun HistoryEntryRow( entry: HistoryEntry, previousValue: Double?, goalValue: Double?, accent: TabAccent, shape: RoundedCornerShape, onEdit: () -> Unit, onDelete: () -> Unit, ) @@ -147,10 +147,10 @@ LongMethod:TrainingScreen.kt$@Composable private fun DashboardRoutineList( routines: List<RoutineSummary>, history: List<WorkoutHistorySummary>, accent: TabAccent, onOpenRoutineDetail: (String) -> Unit, onStartRoutine: (String) -> Unit, onOpenAllRoutines: () -> Unit, ) LongMethod:TrainingScreen.kt$@Composable private fun TrainingDashboard( state: TrainingUiState, accent: TabAccent, onOpenHistory: () -> Unit, onResume: () -> Unit, onDiscardActiveWorkout: () -> Unit, onStartRoutine: (String) -> Unit, onStartBlankWorkout: () -> Unit, onOpenRoutineDetail: (String) -> Unit, onOpenAllRoutines: () -> Unit, onNewRoutine: () -> Unit, onOpenProgress: () -> Unit, onOpenCoach: () -> Unit, ) LongMethod:WorkoutCalculator.kt$WorkoutCalculator$fun exerciseProgress( exerciseId: String, exerciseName: String, equipment: String?, targetMuscles: String, sets: List<ExerciseProgressSetInput>, ): ExerciseProgress - LongParameterList:AccountUi.kt$( name: String, email: String, error: String?, accent: TabAccent, onNameChange: (String) -> Unit, onEmailChange: (String) -> Unit, onDismiss: () -> Unit, onSave: () -> Unit, ) + LongParameterList:AccountUi.kt$( name: String, email: String, error: UiText?, accent: TabAccent, onNameChange: (String) -> Unit, onEmailChange: (String) -> Unit, onDismiss: () -> Unit, onSave: () -> Unit, ) LongParameterList:AddFoodScreen.kt$( state: FoodUiState, onBack: () -> Unit, onQueryChange: (String) -> Unit, onScanClick: () -> Unit, onTabSelected: (AddTab) -> Unit, onFoodClick: (String) -> Unit, onQuickTrack: () -> Unit, onAdjustGoals: () -> Unit, onCopyYesterday: () -> Unit, onSaveTemplate: () -> Unit, onScanLabel: () -> Unit, onProductNameChanged: (String) -> Unit, onBrandChanged: (String) -> Unit, onQuantityChanged: (String) -> Unit, onAmountServingChoiceSelected: (String) -> Unit, onCaloriesChanged: (String) -> Unit, onProteinChanged: (String) -> Unit, onCarbsChanged: (String) -> Unit, onFatChanged: (String) -> Unit, onSaveProduct: () -> Unit, onLogFood: () -> Unit, onCreateRecipe: () -> Unit, // Defaulted so the existing FoodScreen call site keeps compiling; the // orchestrator wires them (selectAddMode / meal retarget / openMealTemplates / // openRecipeBrowser / onKeepAddingFoodsChanged). onModeSelected: (FoodAddMode) -> Unit = {}, onMealRetarget: (String) -> Unit = {}, onOpenTemplates: () -> Unit = {}, onOpenRecipes: () -> Unit = {}, onKeepAddingChanged: (Boolean) -> Unit = {}, // logSavedFood's isSaving guard drops rapid-fire calls, so a plain loop only // logs the first item; a batching VM callback should replace the fallback. onLogAllYesterday: (() -> Unit)? = null, ) LongParameterList:AiCoachSettingsUi.kt$( label: String, value: String, onValueChange: (String) -> Unit, placeholder: String? = null, supporting: String? = null, masked: Boolean = false, keyboardType: KeyboardType = KeyboardType.Text, ) - LongParameterList:AiCoachSettingsUi.kt$( provider: AiCoachProviderKind, baseUrl: String, modelName: String, localAgentKind: LocalAgentKind, apiKey: String, hasSavedApiKey: Boolean, error: String?, accent: TabAccent, onProviderChange: (AiCoachProviderKind) -> Unit, onBaseUrlChange: (String) -> Unit, onModelNameChange: (String) -> Unit, onLocalAgentKindChange: (LocalAgentKind) -> Unit, onApiKeyChange: (String) -> Unit, onDismiss: () -> Unit, onSave: () -> Unit, ) + LongParameterList:AiCoachSettingsUi.kt$( provider: AiCoachProviderKind, baseUrl: String, modelName: String, localAgentKind: LocalAgentKind, apiKey: String, hasSavedApiKey: Boolean, error: UiText?, accent: TabAccent, onProviderChange: (AiCoachProviderKind) -> Unit, onBaseUrlChange: (String) -> Unit, onModelNameChange: (String) -> Unit, onLocalAgentKindChange: (LocalAgentKind) -> Unit, onApiKeyChange: (String) -> Unit, onDismiss: () -> Unit, onSave: () -> Unit, ) LongParameterList:AiCoachSettingsUi.kt$( state: ProfileSettingsUiState, accent: TabAccent, onBack: () -> Unit, onEdit: () -> Unit, onClearApiKey: () -> Unit, onTestConnection: () -> Unit, modifier: Modifier = Modifier, ) LongParameterList:CoachChatViewModel.kt$( date: LocalDate, nutrition: NutritionTotals, foodGoal: FoodGoal, water: FoodWaterSummary, health: DailyHealthSummaryEntity?, userGoals: UserGoals, profile: UserProfile, routines: List<RoutineSummary>, history: List<WorkoutHistorySummary>, ) LongParameterList:CoachChatViewModel.kt$CoachChatViewModel$( private val aiCoachRepository: AiCoachRepository, private val aiCoachChatRepository: AiCoachChatRepository, private val foodRepository: FoodRepository, private val trainingRepository: TrainingRepository, private val healthRepository: HealthRepository, private val goalsRepository: GoalsRepository, private val profileRepository: ProfileRepository, ) @@ -203,7 +203,7 @@ LongParameterList:FoodViewModel.kt$( mealDefinitions: List<FoodMealDefinitionUiState>, useNetCarbs: Boolean, carbsGoalGrams: Double, proteinGoalGrams: Double, fatGoalGrams: Double, fiberGoalGrams: Double, sugarGoalGrams: Double, saturatedFatGoalGrams: Double, sodiumGoalMilligrams: Double, ) LongParameterList:FoodViewModel.kt$( sourceLabel: String, barcode: String?, category: String?, caloriesPer100g: Double, proteinPer100g: Double, carbsPer100g: Double, fatPer100g: Double, isReported: Boolean, ) LongParameterList:FoodViewModel.kt$FoodViewModel$( name: String, sourceLabel: String, caloriesPer100g: String = "250", proteinPer100g: String = "12", carbsPer100g: String = "30", fatPer100g: String = "8", review: String? = "Local estimate: generic meal", message: String, ) - LongParameterList:HealthConnectSettingsUi.kt$( label: String, detail: androidx.compose.ui.text.AnnotatedString, selected: Boolean, enabled: Boolean, accent: TabAccent, shape: RoundedCornerShape, onClick: () -> Unit, ) + LongParameterList:HealthConnectSettingsUi.kt$( label: String, detail: String, selected: Boolean, enabled: Boolean, accent: TabAccent, shape: RoundedCornerShape, onClick: () -> Unit, ) LongParameterList:HealthConnectSettingsUi.kt$( state: ProfileSettingsUiState, accent: TabAccent, onBack: () -> Unit, onRequestPermissions: () -> Unit, onRefresh: () -> Unit, onSync: () -> Unit, onExport: () -> Unit, onSelectStepSource: (String?) -> Unit, modifier: Modifier = Modifier, ) LongParameterList:HermesCoachClient.kt$HermesCoachClient$( connection: AiCoachConnection, endpoint: ValidatedAiCoachEndpoint, relativePath: String, method: String = "GET", bodyJson: String? = null, idempotencyKey: String? = null, remoteSessionId: String? = null, ) LongParameterList:MetricRing.kt$( progress: Float, color: Color, modifier: Modifier = Modifier, diameter: Dp = 72.dp, strokeWidth: Dp = ChartDefaults.ringStroke, trackColor: Color = MusFitTheme.colors.track, content: @Composable () -> Unit, ) @@ -211,7 +211,7 @@ LongParameterList:ProfileEditContent.kt$( initial: UserProfile, initialWeightKg: Double?, targetApplyState: TargetApplyState = TargetApplyState.Idle, targetApplyTargets: RecommendedTargets? = null, onDismiss: () -> Unit, onSave: (UserProfile, Double?) -> Unit, onApplyTargets: ((RecommendedTargets) -> Unit)? = null, ) LongParameterList:ProfileEditContent.kt$( sex: Sex?, ageText: String, heightText: String, currentWeightText: String, activity: ActivityLevel, goalType: GoalType, pace: Double, ) LongParameterList:ProfileExpressiveUi.kt$( label: String, value: String, onValueChange: (String) -> Unit, unit: String, modifier: Modifier = Modifier, keyboardType: KeyboardType = KeyboardType.Decimal, ) - LongParameterList:ProfileExpressiveUi.kt$( options: List<T>, selected: T?, label: (T) -> String, accent: TabAccent, onSelect: (T) -> Unit, modifier: Modifier = Modifier, optionIcon: ((T) -> ImageVector?)? = null, equalWidths: Boolean = false, ) + LongParameterList:ProfileExpressiveUi.kt$( options: List<T>, selected: T?, label: @Composable (T) -> String, accent: TabAccent, onSelect: (T) -> Unit, modifier: Modifier = Modifier, optionIcon: ((T) -> ImageVector?)? = null, equalWidths: Boolean = false, ) LongParameterList:ProfileExpressiveUi.kt$( text: String, accent: TabAccent, onClick: () -> Unit, modifier: Modifier = Modifier, icon: ImageVector? = null, enabled: Boolean = true, ) LongParameterList:ProfileExpressiveUi.kt$( text: String, icon: ImageVector, accent: TabAccent, onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, ) LongParameterList:ProfileExpressiveUi.kt$( title: String, subtitle: String?, shape: RoundedCornerShape, modifier: Modifier = Modifier, onClick: (() -> Unit)? = null, onClickLabel: String? = title, leading: (@Composable () -> Unit)? = null, trailing: (@Composable () -> Unit)? = { HubRowChevron() }, ) diff --git a/feature/profile/build.gradle.kts b/feature/profile/build.gradle.kts index d74530d5..06385ead 100644 --- a/feature/profile/build.gradle.kts +++ b/feature/profile/build.gradle.kts @@ -15,7 +15,10 @@ android { create("legacyMigration") { dimension = "distribution" } } buildTypes { - debug { enableUnitTestCoverage = true } + debug { + enableUnitTestCoverage = true + isPseudoLocalesEnabled = true + } create("benchmark") { initWith(getByName("release")) matchingFallbacks += "release" diff --git a/feature/profile/src/internal/java/com/musfit/ui/profile/AiCoachVariantCopy.kt b/feature/profile/src/internal/java/com/musfit/ui/profile/AiCoachVariantCopy.kt index d1a1141f..5de0c35f 100644 --- a/feature/profile/src/internal/java/com/musfit/ui/profile/AiCoachVariantCopy.kt +++ b/feature/profile/src/internal/java/com/musfit/ui/profile/AiCoachVariantCopy.kt @@ -2,7 +2,3 @@ package com.musfit.ui.profile internal const val HERMES_DEFAULT_BASE_URL = "http://10.0.2.2:8080/v1/" internal const val AI_COACH_BASE_URL_PLACEHOLDER = "http://10.0.2.2:8080/v1/" -internal const val AI_COACH_LOCAL_AGENT_SUMMARY = - "Connects to a local agent on this device or a literal private LAN address." -internal const val AI_COACH_ENDPOINT_POLICY_NOTE = - "HTTP is limited to localhost and literal loopback/private IP addresses; use HTTPS for hostnames." diff --git a/feature/profile/src/internal/res/values/strings.xml b/feature/profile/src/internal/res/values/strings.xml new file mode 100644 index 00000000..58d6c6d4 --- /dev/null +++ b/feature/profile/src/internal/res/values/strings.xml @@ -0,0 +1,4 @@ + + + HTTP is limited to localhost and literal loopback/private IP addresses; use HTTPS for hostnames. + diff --git a/feature/profile/src/legacyMigration/java/com/musfit/ui/profile/AiCoachVariantCopy.kt b/feature/profile/src/legacyMigration/java/com/musfit/ui/profile/AiCoachVariantCopy.kt index fccd9a97..849f2ce9 100644 --- a/feature/profile/src/legacyMigration/java/com/musfit/ui/profile/AiCoachVariantCopy.kt +++ b/feature/profile/src/legacyMigration/java/com/musfit/ui/profile/AiCoachVariantCopy.kt @@ -2,6 +2,3 @@ package com.musfit.ui.profile internal const val HERMES_DEFAULT_BASE_URL = "" internal const val AI_COACH_BASE_URL_PLACEHOLDER = "https://coach.example.com/v1/" -internal const val AI_COACH_LOCAL_AGENT_SUMMARY = - "Connects through a trusted HTTPS endpoint or HTTPS tunnel." -internal const val AI_COACH_ENDPOINT_POLICY_NOTE = "This build sends AI coach requests only to HTTPS endpoints." diff --git a/feature/profile/src/main/java/com/musfit/ui/profile/AccountErasureUi.kt b/feature/profile/src/main/java/com/musfit/ui/profile/AccountErasureUi.kt index 743305d4..7cb091de 100644 --- a/feature/profile/src/main/java/com/musfit/ui/profile/AccountErasureUi.kt +++ b/feature/profile/src/main/java/com/musfit/ui/profile/AccountErasureUi.kt @@ -23,9 +23,11 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp import com.musfit.data.repository.AccountErasureScope +import com.musfit.feature.profile.R import com.musfit.ui.components.ExpressiveBadge import com.musfit.ui.components.ExpressiveBadgeShape import com.musfit.ui.components.InnerScreenHeader @@ -50,23 +52,23 @@ internal fun DataPrivacySettingsPage( verticalArrangement = Arrangement.spacedBy(14.dp), ) { InnerScreenHeader( - title = "Data & privacy", + title = stringResource(R.string.profile_data_privacy), onBack = onBack, - subtitle = "Local storage, retention and erasure", + subtitle = stringResource(R.string.profile_data_privacy_summary), ) - Text("MusFit stores account and fitness data locally on this device. Encrypted export files saved outside the app remain under your control.") + Text(stringResource(R.string.profile_data_privacy_local_storage)) ProfileHubRow( - title = "Encrypted backups", - subtitle = "Export or restore a MusFit backup file", + title = stringResource(R.string.profile_encrypted_backups), + subtitle = stringResource(R.string.profile_encrypted_backups_summary), shape = RoundedCornerShape(24.dp), onClick = onOpenDataTransfer, leading = null, ) Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - ProfileHubRow(title = "Delete this account", subtitle = "Erase this identity and only its local MusFit data", shape = groupedShape(0, 2), onClick = onDeleteAccount, leading = { DestructiveDataBadge() }) - ProfileHubRow(title = "Delete all MusFit data", subtitle = "Erase every local account, then create a fresh local account", shape = groupedShape(1, 2), onClick = onDeleteAllData, leading = { DestructiveDataBadge() }) + ProfileHubRow(title = stringResource(R.string.profile_delete_this_account), subtitle = stringResource(R.string.profile_delete_this_account_summary), shape = groupedShape(0, 2), onClick = onDeleteAccount, leading = { DestructiveDataBadge() }) + ProfileHubRow(title = stringResource(R.string.profile_delete_all_data), subtitle = stringResource(R.string.profile_delete_all_data_summary), shape = groupedShape(1, 2), onClick = onDeleteAllData, leading = { DestructiveDataBadge() }) } - Text("Health Connect deletion is optional and targets only records authored by MusFit.", color = accent.onContainer) + Text(stringResource(R.string.profile_health_deletion_optional), color = accent.onContainer) } } @@ -94,15 +96,15 @@ internal fun AccountErasureDialog( val deletingAll = scope == AccountErasureScope.AllAccounts AlertDialog( onDismissRequest = onDismiss, - title = { Text(if (deletingAll) "Delete all MusFit data?" else "Delete this account?") }, + title = { + Text(stringResource(if (deletingAll) R.string.profile_delete_all_data_question else R.string.profile_delete_this_account_question)) + }, text = { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Text( - if (deletingAll) { - "This permanently removes every local account and all owned Food, Training, profile, AI coach, and Health cache data. A blank local account will be created." - } else { - "This permanently removes the current account and all data it owns. Other local accounts are kept and one becomes active." - }, + stringResource( + if (deletingAll) R.string.profile_delete_all_data_warning else R.string.profile_delete_this_account_warning, + ), ) Row( verticalAlignment = Alignment.CenterVertically, @@ -121,16 +123,16 @@ internal fun AccountErasureDialog( onCheckedChange = null, enabled = !inProgress, ) - Text("Also delete records MusFit authored in Health Connect") + Text(stringResource(R.string.profile_also_delete_health_records)) } - Text("Encrypted export files already saved outside MusFit are not deleted.") + Text(stringResource(R.string.profile_saved_exports_not_deleted)) } }, confirmButton = { TextButton(onClick = onConfirm, enabled = !inProgress) { - Text(if (inProgress) "Deleting..." else "Delete permanently") + Text(stringResource(if (inProgress) R.string.profile_deleting else R.string.profile_delete_permanently)) } }, - dismissButton = { TextButton(onClick = onDismiss, enabled = !inProgress) { Text("Cancel") } }, + dismissButton = { TextButton(onClick = onDismiss, enabled = !inProgress) { Text(stringResource(R.string.profile_cancel)) } }, ) } diff --git a/feature/profile/src/main/java/com/musfit/ui/profile/AccountUi.kt b/feature/profile/src/main/java/com/musfit/ui/profile/AccountUi.kt index 98c753da..60c25e97 100644 --- a/feature/profile/src/main/java/com/musfit/ui/profile/AccountUi.kt +++ b/feature/profile/src/main/java/com/musfit/ui/profile/AccountUi.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics @@ -37,29 +38,42 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.musfit.data.repository.Account +import com.musfit.data.repository.AccountAuthProvider +import com.musfit.feature.profile.R import com.musfit.ui.components.PillButton import com.musfit.ui.components.SheetDragHandle +import com.musfit.ui.text.UiText +import com.musfit.ui.text.asString +import com.musfit.ui.text.uiText import com.musfit.ui.theme.MusFitTheme import com.musfit.ui.theme.TabAccent data class AccountUiState( - val displayName: String = "You", + val displayName: String = "", val email: String? = null, - val providerLabel: String = "Local account", + val provider: AccountAuthProvider = AccountAuthProvider.Local, + val providerLabel: UiText = uiText(R.string.profile_local_account_title), val avatarUrl: String? = null, ) internal fun Account.toUiState() = AccountUiState( displayName = displayName, email = email, - providerLabel = authProvider.displayName, + provider = authProvider, + providerLabel = authProvider.profileLabel(), avatarUrl = avatarUrl, ) +private fun AccountAuthProvider.profileLabel(): UiText = when (this) { + AccountAuthProvider.Local -> uiText(R.string.profile_local_account_title) + AccountAuthProvider.Google -> uiText(R.string.profile_google) + AccountAuthProvider.GitHub -> uiText(R.string.profile_github) +} + /** Initials monogram: first letters of the first two words ("Max Berger" → "MB"). */ internal fun String.accountInitials(): String = trim().split(Regex("\\s+")).filter { it.isNotBlank() } .take(2) - .mapNotNull { it.firstOrNull()?.uppercaseChar() } + .mapNotNull { word -> word.firstOrNull(Char::isLetterOrDigit)?.uppercaseChar() } .joinToString("") .ifEmpty { "Y" } @@ -69,6 +83,7 @@ internal fun String.accountInitials(): String = trim().split(Regex("\\s+")).filt */ @Composable internal fun LocalAccountHero(account: AccountUiState, accent: TabAccent, onEdit: () -> Unit) { + val displayName = account.displayName.ifBlank { stringResource(R.string.profile_you) } Surface( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(28.dp), @@ -89,24 +104,24 @@ internal fun LocalAccountHero(account: AccountUiState, accent: TabAccent, onEdit contentAlignment = Alignment.Center, ) { Text( - account.displayName.accountInitials(), + displayName.accountInitials(), style = MusFitTheme.typography.titleLarge.copy(fontSize = 17.sp), color = accent.onContainer, ) } Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { Text( - account.displayName, + displayName, style = MusFitTheme.typography.titleLarge.copy(fontSize = 17.sp, letterSpacing = (-0.2).sp), color = accent.onContainer, ) Text( - "${account.providerLabel} · data stays on this device", + stringResource(R.string.profile_account_device_summary, account.providerLabel.asString()), style = MusFitTheme.typography.bodySmall, color = accent.onContainerVariant, ) } - HeroChip(text = "Edit", accent = accent, onClick = onEdit) + HeroChip(text = stringResource(R.string.profile_edit), accent = accent, onClick = onEdit) } } } @@ -119,7 +134,7 @@ internal fun LocalAccountHero(account: AccountUiState, accent: TabAccent, onEdit fun AccountEditSheet( name: String, email: String, - error: String?, + error: UiText?, accent: TabAccent, onNameChange: (String) -> Unit, onEmailChange: (String) -> Unit, @@ -147,33 +162,33 @@ fun AccountEditSheet( ) { Column { Text( - "Local account", + stringResource(R.string.profile_local_account_title), style = MusFitTheme.typography.headlineMedium.copy(fontSize = 22.sp, lineHeight = 25.sp), ) Text( - "Stored on this device. Sync and sign-in are not enabled.", + stringResource(R.string.profile_account_sheet_subtitle), style = MusFitTheme.typography.bodySmall, color = MusFitTheme.colors.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp), ) } - AccountTextTile(label = "Name", value = name, onValueChange = onNameChange) + AccountTextTile(label = stringResource(R.string.profile_name), value = name, onValueChange = onNameChange) AccountTextTile( - label = "Email (optional)", + label = stringResource(R.string.profile_email_optional), value = email, onValueChange = onEmailChange, keyboardType = KeyboardType.Email, ) if (error != null) { Text( - error, + error.asString(), style = MusFitTheme.typography.bodySmall, color = MusFitTheme.colors.onDestructiveContainer, modifier = Modifier.padding(horizontal = 4.dp), ) } PillButton( - text = "Save", + text = stringResource(R.string.profile_save), onClick = onSave, containerColor = accent.color, contentColor = accent.onColor, diff --git a/feature/profile/src/main/java/com/musfit/ui/profile/AiCoachSettingsUi.kt b/feature/profile/src/main/java/com/musfit/ui/profile/AiCoachSettingsUi.kt index ed75be1b..89890ea5 100644 --- a/feature/profile/src/main/java/com/musfit/ui/profile/AiCoachSettingsUi.kt +++ b/feature/profile/src/main/java/com/musfit/ui/profile/AiCoachSettingsUi.kt @@ -30,6 +30,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription @@ -43,10 +44,13 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.musfit.data.repository.AiCoachProviderKind import com.musfit.data.repository.LocalAgentKind +import com.musfit.feature.profile.R import com.musfit.ui.components.InnerScreenHeader import com.musfit.ui.components.PillButton import com.musfit.ui.components.SheetDragHandle import com.musfit.ui.components.groupedShape +import com.musfit.ui.text.UiText +import com.musfit.ui.text.asString import com.musfit.ui.theme.MusFitTheme import com.musfit.ui.theme.TabAccent @@ -65,7 +69,6 @@ internal fun AiCoachSettingsPage( onTestConnection: () -> Unit, modifier: Modifier = Modifier, ) { - val coach = state.aiCoach Column( modifier = modifier .fillMaxSize() @@ -75,8 +78,8 @@ internal fun AiCoachSettingsPage( verticalArrangement = Arrangement.spacedBy(14.dp), ) { InnerScreenHeader( - title = "AI coach", - subtitle = "The coach runs on your own model connection", + title = stringResource(R.string.profile_ai_title), + subtitle = stringResource(R.string.profile_ai_subtitle), onBack = onBack, ) @@ -86,7 +89,7 @@ internal fun AiCoachSettingsPage( onTestConnection = onTestConnection, ) - val message = state.aiCoachMessage + val message = state.aiCoachMessage?.asString() if (message != null) { Text( message, @@ -96,31 +99,57 @@ internal fun AiCoachSettingsPage( ) } - GroupLabel( - text = "Connection", - actionLabel = "Edit", - actionColor = accent.color, - onAction = onEdit, + AiCoachConnectionDetails( + coach = state.aiCoach, + accent = accent, + onEdit = onEdit, + onClearApiKey = onClearApiKey, ) - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - CompactValueRow(title = "Provider", value = coach.providerLabel, shape = groupedShape(0, 4)) - CompactValueRow(title = "Base URL", value = coach.endpointLabel, shape = groupedShape(1, 4)) - CompactValueRow(title = "Model", value = coach.modelLabel, shape = groupedShape(2, 4)) - ApiKeyRow( - hasApiKey = coach.hasApiKey, - accent = accent, - shape = groupedShape(3, 4), - onClear = onClearApiKey, - ) - } + } +} - Text( - "Your key stays on this device.", - style = MusFitTheme.typography.bodySmall.copy(fontSize = 12.sp), - color = MusFitTheme.colors.onSurfaceFaint, - modifier = Modifier.padding(horizontal = 4.dp), +@Composable +private fun AiCoachConnectionDetails( + coach: AiCoachSettingsUiState, + accent: TabAccent, + onEdit: () -> Unit, + onClearApiKey: () -> Unit, +) { + GroupLabel( + text = stringResource(R.string.profile_connection), + actionLabel = stringResource(R.string.profile_edit), + actionColor = accent.color, + onAction = onEdit, + ) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + CompactValueRow( + title = stringResource(R.string.profile_provider), + value = coach.providerLabel.asString(), + shape = groupedShape(0, 4), + ) + CompactValueRow( + title = stringResource(R.string.profile_base_url), + value = coach.endpointLabel.asString(), + shape = groupedShape(1, 4), + ) + CompactValueRow( + title = stringResource(R.string.profile_model), + value = coach.modelLabel.asString(), + shape = groupedShape(2, 4), + ) + ApiKeyRow( + hasApiKey = coach.hasApiKey, + accent = accent, + shape = groupedShape(3, 4), + onClear = onClearApiKey, ) } + Text( + stringResource(R.string.profile_key_device_note), + style = MusFitTheme.typography.bodySmall.copy(fontSize = 12.sp), + color = MusFitTheme.colors.onSurfaceFaint, + modifier = Modifier.padding(horizontal = 4.dp), + ) } /** Tonal hero: white mark circle · name + status dot line · filled Test pill. */ @@ -145,14 +174,14 @@ private fun AiCoachConnectionHero( } Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { Text( - if (disabled) "Not set up" else coach.providerLabel, + if (disabled) stringResource(R.string.profile_not_set_up) else coach.providerLabel.asString(), style = MusFitTheme.typography.titleLarge.copy(fontSize = 19.sp, letterSpacing = (-0.3).sp), color = accent.onContainer, ) AiCoachStatusLine(state = state, accent = accent) } HeroActionPill( - text = if (state.isAiCoachTesting) "Testing" else "Test", + text = stringResource(if (state.isAiCoachTesting) R.string.profile_testing else R.string.profile_test), accent = accent, onClick = onTestConnection, enabled = !disabled && !state.isAiCoachTesting, @@ -166,16 +195,16 @@ private fun AiCoachConnectionHero( private fun AiCoachStatusLine(state: ProfileSettingsUiState, accent: TabAccent) { val disabled = state.aiCoach.providerKind == AiCoachProviderKind.Disabled val (dotColor, label) = when { - disabled -> MusFitTheme.colors.onSurfaceFaint to "Off" + disabled -> MusFitTheme.colors.onSurfaceFaint to stringResource(R.string.profile_off) - state.aiCoachTestState == AiCoachTestState.Testing -> accent.onContainerVariant to "Testing…" + state.aiCoachTestState == AiCoachTestState.Testing -> accent.onContainerVariant to stringResource(R.string.profile_ai_testing) - state.aiCoachTestState == AiCoachTestState.Success -> accent.color to "Connected" + state.aiCoachTestState == AiCoachTestState.Success -> accent.color to stringResource(R.string.profile_connected) state.aiCoachTestState == AiCoachTestState.Failure -> - MusFitTheme.colors.onDestructiveContainer to "Not reachable" + MusFitTheme.colors.onDestructiveContainer to stringResource(R.string.profile_not_reachable) - else -> accent.onContainerVariant to "Not tested yet" + else -> accent.onContainerVariant to stringResource(R.string.profile_not_tested) } Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) { Box(Modifier.size(7.dp).background(dotColor, CircleShape)) @@ -202,13 +231,13 @@ private fun ApiKeyRow( ) { Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) { Text( - "API key", + stringResource(R.string.profile_api_key), style = MusFitTheme.typography.titleSmall.copy(fontSize = 14.5.sp), color = MusFitTheme.colors.onSurface, ) Text( // The key is stored encrypted, so only its presence can be shown. - if (hasApiKey) "•••• •••• saved" else "No key", + stringResource(if (hasApiKey) R.string.profile_api_key_masked_saved else R.string.profile_no_key), style = MusFitTheme.typography.bodySmall.copy(fontSize = 12.sp), color = MusFitTheme.colors.onSurfaceVariant, ) @@ -224,7 +253,7 @@ private fun ApiKeyRow( ) { Box(contentAlignment = Alignment.Center, modifier = Modifier.padding(horizontal = 8.dp)) { Text( - "Clear", + stringResource(R.string.profile_clear), style = MusFitTheme.typography.labelLarge.copy( fontSize = 13.sp, fontWeight = FontWeight.W800, @@ -251,7 +280,7 @@ fun AiCoachEditorSheet( localAgentKind: LocalAgentKind, apiKey: String, hasSavedApiKey: Boolean, - error: String?, + error: UiText?, accent: TabAccent, onProviderChange: (AiCoachProviderKind) -> Unit, onBaseUrlChange: (String) -> Unit, @@ -282,11 +311,11 @@ fun AiCoachEditorSheet( ) { Column { Text( - "AI coach setup", + stringResource(R.string.profile_ai_setup), style = MusFitTheme.typography.headlineMedium.copy(fontSize = 22.sp, lineHeight = 25.sp), ) Text( - "Bring your own agent or API-compatible endpoint", + stringResource(R.string.profile_ai_setup_subtitle), style = MusFitTheme.typography.bodySmall, color = MusFitTheme.colors.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp), @@ -303,7 +332,7 @@ fun AiCoachEditorSheet( if (provider != AiCoachProviderKind.Disabled) { EditorTextTile( - label = "Base URL", + label = stringResource(R.string.profile_base_url), value = baseUrl, onValueChange = onBaseUrlChange, placeholder = AI_COACH_BASE_URL_PLACEHOLDER, @@ -322,9 +351,9 @@ fun AiCoachEditorSheet( if (provider != AiCoachProviderKind.Disabled) { EditorTextTile( label = if (provider == AiCoachProviderKind.OpenAiCompatible) { - "Model" + stringResource(R.string.profile_model) } else { - "Model or agent id (optional)" + stringResource(R.string.profile_model_or_agent_id) }, value = modelName, onValueChange = onModelNameChange, @@ -343,7 +372,7 @@ fun AiCoachEditorSheet( } if (error != null) { Text( - error, + error.asString(), style = MusFitTheme.typography.bodySmall, color = MusFitTheme.colors.onDestructiveContainer, modifier = Modifier.padding(horizontal = 4.dp), @@ -357,7 +386,7 @@ fun AiCoachEditorSheet( ) PillButton( - text = "Save", + text = stringResource(R.string.profile_save), onClick = onSave, containerColor = accent.color, contentColor = accent.onColor, @@ -453,24 +482,28 @@ private fun EditorTextTile( } } +@Composable private fun AiCoachProviderKind.shortLabel(): String = when (this) { - AiCoachProviderKind.Disabled -> "Off" - AiCoachProviderKind.OpenAiCompatible -> "API" - AiCoachProviderKind.LocalAgent -> "Agent" + AiCoachProviderKind.Disabled -> stringResource(R.string.profile_off) + AiCoachProviderKind.OpenAiCompatible -> stringResource(R.string.profile_api_short) + AiCoachProviderKind.LocalAgent -> stringResource(R.string.profile_agent_short) } +@Composable private fun LocalAgentKind.shortLabel(): String = when (this) { - LocalAgentKind.OpenClaw -> "OpenClaw" - LocalAgentKind.HermesAgent -> "Hermes" - LocalAgentKind.Custom -> "Custom" + LocalAgentKind.OpenClaw -> stringResource(R.string.profile_openclaw_short) + LocalAgentKind.HermesAgent -> stringResource(R.string.profile_hermes_short) + LocalAgentKind.Custom -> stringResource(R.string.profile_custom) } +@Composable private fun apiKeyFieldLabel(provider: AiCoachProviderKind, localAgentKind: LocalAgentKind): String = if (provider == AiCoachProviderKind.LocalAgent && localAgentKind == LocalAgentKind.HermesAgent) { - "API_SERVER_KEY" + stringResource(R.string.profile_api_server_key) } else { - "API key (optional)" + stringResource(R.string.profile_api_key_optional) } +@Composable private fun apiKeySupportingText( provider: AiCoachProviderKind, localAgentKind: LocalAgentKind, @@ -478,15 +511,16 @@ private fun apiKeySupportingText( ): String { val isHermes = provider == AiCoachProviderKind.LocalAgent && localAgentKind == LocalAgentKind.HermesAgent return when { - isHermes && hasSavedApiKey -> "Leave blank to keep the saved API_SERVER_KEY." - isHermes -> "Paste API_SERVER_KEY from ~/.hermes/.env. Stored encrypted on this device." - hasSavedApiKey -> "Leave blank to keep the saved key." - else -> "Stored encrypted on this device." + isHermes && hasSavedApiKey -> stringResource(R.string.profile_keep_api_server_key) + isHermes -> stringResource(R.string.profile_paste_api_server_key) + hasSavedApiKey -> stringResource(R.string.profile_keep_saved_key) + else -> stringResource(R.string.profile_key_encrypted) } } +@Composable private fun aiCoachSecurityNote(provider: AiCoachProviderKind, localAgentKind: LocalAgentKind): String = if (provider == AiCoachProviderKind.LocalAgent && localAgentKind == LocalAgentKind.HermesAgent) { - "Hermes requires the API_SERVER_KEY bearer token from ~/.hermes/.env. $AI_COACH_ENDPOINT_POLICY_NOTE" + stringResource(R.string.profile_hermes_security_note, stringResource(R.string.profile_ai_endpoint_policy_note)) } else { - "Keys stay encrypted on this device. $AI_COACH_ENDPOINT_POLICY_NOTE" + stringResource(R.string.profile_key_security_note, stringResource(R.string.profile_ai_endpoint_policy_note)) } diff --git a/feature/profile/src/main/java/com/musfit/ui/profile/GoogleSignIn.kt b/feature/profile/src/main/java/com/musfit/ui/profile/GoogleSignIn.kt index e37adb68..a7c424f8 100644 --- a/feature/profile/src/main/java/com/musfit/ui/profile/GoogleSignIn.kt +++ b/feature/profile/src/main/java/com/musfit/ui/profile/GoogleSignIn.kt @@ -10,13 +10,14 @@ import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException import com.musfit.data.repository.AccountAuthProvider import com.musfit.data.repository.ExternalAccountProfile +import com.musfit.feature.profile.R internal suspend fun signInWithGoogle( context: Context, googleWebClientId: String, ): ExternalAccountProfile { if (googleWebClientId.isBlank()) { - throw IllegalStateException("Google sign-in is not configured.") + throw IllegalStateException(context.getString(R.string.profile_google_not_configured)) } val googleOption = GetSignInWithGoogleOption.Builder(googleWebClientId).build() val request = @@ -32,31 +33,31 @@ internal suspend fun signInWithGoogle( .getCredential(context = context, request = request) .credential } catch (error: NoCredentialException) { - throw IllegalStateException("No Google account is available for sign-in.", error) + throw IllegalStateException(context.getString(R.string.profile_google_no_account), error) } if (credential !is CustomCredential || credential.type != GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL ) { - throw IllegalStateException("Google sign-in did not return a Google account.") + throw IllegalStateException(context.getString(R.string.profile_google_invalid_account)) } val googleAccount = try { GoogleIdTokenCredential.createFrom(credential.data) } catch (error: GoogleIdTokenParsingException) { - throw IllegalStateException("Google sign-in returned an unreadable credential.", error) + throw IllegalStateException(context.getString(R.string.profile_google_unreadable_credential), error) } val email = googleAccount.email?.takeIf { it.isNotBlank() } val providerUserId = googleAccount.uniqueId?.takeIf { it.isNotBlank() } ?: email ?: googleAccount.id.takeIf { it.isNotBlank() } - ?: throw IllegalStateException("Google sign-in did not return an account id.") + ?: throw IllegalStateException(context.getString(R.string.profile_google_missing_account_id)) val displayName = googleAccount.displayName?.takeIf { it.isNotBlank() } ?: email - ?: "Google user" + ?: context.getString(R.string.profile_google_user) return ExternalAccountProfile( provider = AccountAuthProvider.Google, providerUserId = providerUserId, diff --git a/feature/profile/src/main/java/com/musfit/ui/profile/HealthConnectSettingsUi.kt b/feature/profile/src/main/java/com/musfit/ui/profile/HealthConnectSettingsUi.kt index 888213a0..a7f9ec9b 100644 --- a/feature/profile/src/main/java/com/musfit/ui/profile/HealthConnectSettingsUi.kt +++ b/feature/profile/src/main/java/com/musfit/ui/profile/HealthConnectSettingsUi.kt @@ -22,16 +22,21 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.musfit.domain.health.StepSource +import com.musfit.feature.profile.R import com.musfit.ui.components.InnerScreenHeader import com.musfit.ui.components.groupedShape +import com.musfit.ui.text.LocalizedFormatter +import com.musfit.ui.text.UiText +import com.musfit.ui.text.asString +import com.musfit.ui.text.uiText import com.musfit.ui.theme.MusFitTheme import com.musfit.ui.theme.TabAccent @@ -62,8 +67,8 @@ internal fun HealthConnectSettingsPage( verticalArrangement = Arrangement.spacedBy(14.dp), ) { InnerScreenHeader( - title = "Health Connect", - subtitle = "Mirrors steps, weight and heart rate", + title = stringResource(R.string.profile_health_connect), + subtitle = stringResource(R.string.profile_health_connect_subtitle), onBack = onBack, ) @@ -71,14 +76,14 @@ internal fun HealthConnectSettingsPage( Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { TonalActionPill( - text = "Refresh", + text = stringResource(R.string.profile_refresh), icon = Icons.Outlined.Refresh, accent = accent, onClick = onRefresh, modifier = Modifier.weight(1f), ) TonalActionPill( - text = if (state.isHealthConnectSyncing) "Syncing" else "Sync", + text = stringResource(if (state.isHealthConnectSyncing) R.string.profile_syncing else R.string.profile_sync), icon = Icons.Outlined.Sync, accent = accent, onClick = onSync, @@ -86,7 +91,7 @@ internal fun HealthConnectSettingsPage( modifier = Modifier.weight(1f), ) TonalActionPill( - text = "Export", + text = stringResource(R.string.profile_export), icon = Icons.Outlined.Upload, accent = accent, onClick = onExport, @@ -95,16 +100,15 @@ internal fun HealthConnectSettingsPage( } Text( - state.message, + state.message.asString(), style = MusFitTheme.typography.bodySmall, color = MusFitTheme.colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 4.dp), ) - GroupLabel("Steps source") + GroupLabel(stringResource(R.string.profile_steps_source)) Text( - "The combined total can read higher than a single app because it merges every source. " + - "Pick one to mirror it exactly.", + stringResource(R.string.profile_steps_source_explanation), style = MusFitTheme.typography.bodySmall.copy(fontSize = 12.sp), color = MusFitTheme.colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 4.dp), @@ -112,7 +116,7 @@ internal fun HealthConnectSettingsPage( StepSourceList( sources = state.stepSources, selectedPackage = state.preferredStepsPackage, - enabled = state.availabilityLabel == "Available" && !state.isHealthConnectSyncing, + enabled = state.availabilityLabel == uiText(R.string.profile_health_available) && !state.isHealthConnectSyncing, accent = accent, onSelect = onSelectStepSource, ) @@ -139,22 +143,26 @@ private fun PermissionsHero( verticalAlignment = Alignment.CenterVertically, ) { Text( - "DEVICE SYNC", + stringResource(R.string.profile_device_sync), style = MusFitTheme.typography.labelSmall.copy(fontWeight = FontWeight.W800, letterSpacing = 0.8.sp), color = accent.onContainer, ) - HeroChip(text = availabilityPillLabel(state.availabilityLabel), accent = accent) + HeroChip(text = availabilityPillLabel(state.availabilityLabel).asString(), accent = accent) } if (total > 0) { Row(verticalAlignment = Alignment.Bottom) { Text( - "$granted of $total", + stringResource( + R.string.profile_permissions_granted_count, + LocalizedFormatter.integer(granted.toLong(), locale = LocalConfiguration.current.locales[0]), + LocalizedFormatter.integer(total.toLong(), locale = LocalConfiguration.current.locales[0]), + ), style = MusFitTheme.typography.displayMedium.copy(fontSize = 44.sp, lineHeight = 44.sp), color = accent.onContainer, maxLines = 1, ) Text( - "permissions granted", + stringResource(R.string.profile_permissions_granted), style = MusFitTheme.typography.bodyMedium.copy(fontSize = 14.sp), fontWeight = FontWeight.Medium, color = accent.onContainer, @@ -164,7 +172,7 @@ private fun PermissionsHero( PermissionSegments(granted = granted, total = total, accent = accent) } else { Text( - "Refresh to check available health permissions.", + stringResource(R.string.profile_refresh_permissions), style = MusFitTheme.typography.bodyMedium, color = accent.onContainerVariant, ) @@ -176,8 +184,14 @@ private fun PermissionsHero( ) { Text( text = when { - total > 0 && missing == 0 -> "All permissions granted" - total > 0 -> "$missing more to grant" + total > 0 && missing == 0 -> stringResource(R.string.profile_all_permissions_granted) + + total > 0 -> pluralStringResource( + R.plurals.profile_permissions_more_to_grant, + missing, + LocalizedFormatter.integer(missing.toLong(), locale = LocalConfiguration.current.locales[0]), + ) + else -> "" }, style = MusFitTheme.typography.bodySmall.copy(fontSize = 12.sp), @@ -186,7 +200,15 @@ private fun PermissionsHero( ) if (state.canRequestPermissions) { HeroActionPill( - text = if (missing > 0) "Grant $missing more" else "Review", + text = if (missing > 0) { + pluralStringResource( + R.plurals.profile_grant_more, + missing, + LocalizedFormatter.integer(missing.toLong(), locale = LocalConfiguration.current.locales[0]), + ) + } else { + stringResource(R.string.profile_review) + }, accent = accent, onClick = onRequestPermissions, ) @@ -210,8 +232,8 @@ private fun StepSourceList( modifier = Modifier.selectableGroup(), ) { StepSourceRow( - label = "All sources (unified)", - detail = buildAnnotatedString { append("Health Connect combined total") }, + label = stringResource(R.string.profile_all_step_sources), + detail = stringResource(R.string.profile_health_combined_total), selected = selectedPackage == null, enabled = enabled, accent = accent, @@ -221,12 +243,10 @@ private fun StepSourceList( sources.forEachIndexed { index, source -> StepSourceRow( label = source.label, - detail = buildAnnotatedString { - withStyle(SpanStyle(fontWeight = FontWeight.W800, color = MusFitTheme.colors.onSurface)) { - append("%,d".format(source.steps)) - } - append(" steps today") - }, + detail = stringResource( + R.string.profile_steps_today, + LocalizedFormatter.integer(source.steps, locale = LocalConfiguration.current.locales[0]), + ), selected = selectedPackage == source.packageName, enabled = enabled, accent = accent, @@ -236,7 +256,7 @@ private fun StepSourceList( } if (sources.isEmpty()) { Text( - "No step sources found for today yet. Sync or walk a little, then come back.", + stringResource(R.string.profile_no_step_sources), style = MusFitTheme.typography.bodySmall, color = MusFitTheme.colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp), @@ -248,7 +268,7 @@ private fun StepSourceList( @Composable private fun StepSourceRow( label: String, - detail: androidx.compose.ui.text.AnnotatedString, + detail: String, selected: Boolean, enabled: Boolean, accent: TabAccent, @@ -290,7 +310,7 @@ private fun StepSourceRow( } } -private fun availabilityPillLabel(availabilityLabel: String): String = when (availabilityLabel) { - "Install or update required" -> "Needs app" +private fun availabilityPillLabel(availabilityLabel: UiText): UiText = when (availabilityLabel) { + uiText(R.string.profile_health_install_required) -> uiText(R.string.profile_needs_app) else -> availabilityLabel } diff --git a/feature/profile/src/main/java/com/musfit/ui/profile/ProfileEditContent.kt b/feature/profile/src/main/java/com/musfit/ui/profile/ProfileEditContent.kt index e72a5d27..ecdd351a 100644 --- a/feature/profile/src/main/java/com/musfit/ui/profile/ProfileEditContent.kt +++ b/feature/profile/src/main/java/com/musfit/ui/profile/ProfileEditContent.kt @@ -49,6 +49,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.role @@ -67,8 +69,10 @@ import com.musfit.domain.profile.EnergyCalculator import com.musfit.domain.profile.GoalType import com.musfit.domain.profile.RecommendedTargets import com.musfit.domain.profile.Sex +import com.musfit.feature.profile.R import com.musfit.ui.components.PillButton import com.musfit.ui.components.SheetDragHandle +import com.musfit.ui.text.LocalizedFormatter import com.musfit.ui.theme.LavenderBody import com.musfit.ui.theme.LavenderBodyDark import com.musfit.ui.theme.LavenderContainer @@ -81,16 +85,6 @@ import com.musfit.ui.theme.TabAccentRole import com.musfit.ui.theme.tabAccentFor import java.time.LocalDate import java.time.Period -import java.util.Locale - -private val MEASUREMENT_LABELS = mapOf( - "waist" to "Waist", - "chest" to "Chest", - "arms" to "Arms", - "thighs" to "Thighs", - "hips" to "Hips", - "body_fat" to "Body fat", -) private const val PACE_STEP = 0.1 private const val PACE_MIN = 0.1 @@ -168,11 +162,11 @@ fun ProfileEditSheet( Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { Column(modifier = Modifier.weight(1f)) { Text( - "Your profile", + stringResource(R.string.profile_your_profile), style = MusFitTheme.typography.headlineMedium.copy(fontSize = 22.sp, lineHeight = 25.sp), ) Text( - "Sets your calorie and macro targets", + stringResource(R.string.profile_targets_explanation), style = MusFitTheme.typography.bodySmall, color = MusFitTheme.colors.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp), @@ -180,59 +174,59 @@ fun ProfileEditSheet( } ProfileCircleIconButton( icon = Icons.Outlined.Close, - contentDescription = "Close", + contentDescription = stringResource(R.string.profile_close), container = MusFitTheme.colors.surfaceVariant, content = MusFitTheme.colors.onSurface, onClick = onDismiss, ) } - SheetFieldLabel("Sex") + SheetFieldLabel(stringResource(R.string.profile_sex)) ConnectedSegmentRow( options = listOf(Sex.Male, Sex.Female), selected = sex, - label = { it.label() }, + label = { stringResource(it.labelResource()) }, accent = accent, onSelect = { sexName = it.name }, ) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { ProfileFieldTile( - label = "Age", + label = stringResource(R.string.profile_age), value = ageText, onValueChange = { ageText = it }, - unit = "years", + unit = stringResource(R.string.profile_unit_years), keyboardType = KeyboardType.Number, modifier = Modifier.weight(1f), ) ProfileFieldTile( - label = "Height", + label = stringResource(R.string.profile_height), value = heightText, onValueChange = { heightText = it }, - unit = "cm", + unit = stringResource(R.string.profile_unit_cm), modifier = Modifier.weight(1f), ) } - SheetFieldLabel("Activity level") + SheetFieldLabel(stringResource(R.string.profile_activity_level)) // Five levels don't fit one connected row; split into two rows that // share the selection (a documented deviation from the 3-segment mock). ConnectedSegmentRow( options = listOf(ActivityLevel.Sedentary, ActivityLevel.Light, ActivityLevel.Moderate), selected = activity, - label = { it.label() }, + label = { stringResource(it.labelResource()) }, accent = accent, onSelect = { activityName = it.name }, ) ConnectedSegmentRow( options = listOf(ActivityLevel.Active, ActivityLevel.VeryActive), selected = activity, - label = { it.label() }, + label = { stringResource(it.labelResource()) }, accent = accent, onSelect = { activityName = it.name }, ) - SheetFieldLabel("Goal") + SheetFieldLabel(stringResource(R.string.profile_goal)) ConnectedSegmentRow( options = GoalType.entries, selected = goalType, @@ -253,17 +247,17 @@ fun ProfileEditSheet( Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { ProfileFieldTile( - label = "Goal weight", + label = stringResource(R.string.profile_goal_weight_label), value = goalWeightText, onValueChange = { goalWeightText = it }, - unit = "kg", + unit = stringResource(R.string.profile_unit_kg), modifier = Modifier.weight(1f), ) ProfileFieldTile( - label = "Current weight", + label = stringResource(R.string.profile_current_weight), value = currentWeightText, onValueChange = { currentWeightText = it }, - unit = "kg", + unit = stringResource(R.string.profile_unit_kg), modifier = Modifier.weight(1f), ) } @@ -278,7 +272,7 @@ fun ProfileEditSheet( } PillButton( - text = "Save", + text = stringResource(R.string.profile_save), onClick = { val ageYears = ageText.trim().toIntOrNull() val profile = UserProfile( @@ -337,18 +331,18 @@ private fun PaceMiniHero( ) { Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { Text( - "PACE", + stringResource(R.string.profile_pace), style = MusFitTheme.typography.labelSmall.copy(fontWeight = FontWeight.W800, letterSpacing = 0.8.sp), color = accent.onContainer, ) Row(verticalAlignment = Alignment.Bottom) { Text( - pace.format1(), + pace.format1(LocalConfiguration.current.locales[0]), style = MusFitTheme.typography.displaySmall.copy(fontSize = 24.sp, lineHeight = 24.sp), color = accent.onContainer, ) Text( - "kg/week", + stringResource(R.string.profile_unit_kg_per_week), style = MusFitTheme.typography.bodySmall.copy(fontSize = 12.sp), fontWeight = FontWeight.Medium, color = accent.onContainerVariant, @@ -358,14 +352,14 @@ private fun PaceMiniHero( } PaceStepperCircle( icon = Icons.Outlined.Remove, - contentDescription = "Decrease pace", + contentDescription = stringResource(R.string.profile_decrease_pace), container = MusFitTheme.colors.surface, content = accent.onContainer, onClick = onDecrease, ) PaceStepperCircle( icon = Icons.Outlined.Add, - contentDescription = "Increase pace", + contentDescription = stringResource(R.string.profile_increase_pace), container = accent.color, content = accent.onColor, onClick = onIncrease, @@ -440,12 +434,6 @@ private fun ApplyTargetsBanner( val body = if (dark) LavenderBodyDark else LavenderBody val action = if (dark) LavenderInkDark else LavenderInk val stateForTargets = targetApplyStateForTargets(applyState, requestedTargets, targets) - val actionLabel = when (stateForTargets) { - TargetApplyState.Idle -> "Apply to Food" - TargetApplyState.Applying -> "Applying…" - TargetApplyState.Success -> "Applied" - TargetApplyState.Failure -> "Retry" - } val actionEnabled = applyState != TargetApplyState.Applying && stateForTargets != TargetApplyState.Success Surface(color = container, shape = RoundedCornerShape(99.dp), modifier = Modifier.fillMaxWidth()) { Row( @@ -453,41 +441,88 @@ private fun ApplyTargetsBanner( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Text( - buildAnnotatedString { - append("New targets: ") - withStyle(SpanStyle(fontWeight = FontWeight.W800)) { - append(String.format(Locale.US, "%,d kcal", targets.caloriesKcal.toInt())) - } - append(" · ${targets.proteinGrams.toInt()} g protein") - if (stateForTargets == TargetApplyState.Failure) { - append("\nCouldn't apply targets. Try again.") - } - }, - style = MusFitTheme.typography.bodySmall.copy(fontSize = 12.sp), + ApplyTargetsSummary( + targets = targets, + state = stateForTargets, color = body, modifier = Modifier.weight(1f), ) - Surface( - onClick = { onApply(targets) }, + ApplyTargetsAction( + state = stateForTargets, enabled = actionEnabled, - color = androidx.compose.ui.graphics.Color.Transparent, - shape = RoundedCornerShape(99.dp), - modifier = Modifier - .sizeIn(minWidth = 48.dp, minHeight = 48.dp) - .semantics { role = Role.Button }, - ) { - Box(contentAlignment = Alignment.Center, modifier = Modifier.padding(horizontal = 8.dp)) { - Text( - actionLabel, - style = MusFitTheme.typography.labelMedium.copy( - fontSize = 12.5.sp, - fontWeight = FontWeight.W800, - ), - color = action, - ) - } - } + color = action, + onClick = { onApply(targets) }, + ) + } + } +} + +@Composable +private fun ApplyTargetsSummary( + targets: RecommendedTargets, + state: TargetApplyState, + color: androidx.compose.ui.graphics.Color, + modifier: Modifier, +) { + val locale = LocalConfiguration.current.locales[0] + val summary = buildAnnotatedString { + append(stringResource(R.string.profile_new_targets_prefix)) + withStyle(SpanStyle(fontWeight = FontWeight.W800)) { + append( + stringResource( + R.string.profile_value_kcal, + LocalizedFormatter.integer(targets.caloriesKcal.toLong(), locale = locale), + ), + ) + } + append(stringResource(R.string.profile_separator_middle_dot)) + append( + stringResource( + R.string.profile_value_protein_grams, + LocalizedFormatter.integer(targets.proteinGrams.toLong(), locale = locale), + ), + ) + if (state == TargetApplyState.Failure) { + append('\n') + append(stringResource(R.string.profile_apply_targets_failed)) + } + } + Text( + summary, + style = MusFitTheme.typography.bodySmall.copy(fontSize = 12.sp), + color = color, + modifier = modifier, + ) +} + +@Composable +private fun ApplyTargetsAction( + state: TargetApplyState, + enabled: Boolean, + color: androidx.compose.ui.graphics.Color, + onClick: () -> Unit, +) { + val label = when (state) { + TargetApplyState.Idle -> stringResource(R.string.profile_apply_to_food) + TargetApplyState.Applying -> stringResource(R.string.profile_applying) + TargetApplyState.Success -> stringResource(R.string.profile_applied) + TargetApplyState.Failure -> stringResource(R.string.profile_retry) + } + Surface( + onClick = onClick, + enabled = enabled, + color = androidx.compose.ui.graphics.Color.Transparent, + shape = RoundedCornerShape(99.dp), + modifier = Modifier + .sizeIn(minWidth = 48.dp, minHeight = 48.dp) + .semantics { role = Role.Button }, + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.padding(horizontal = 8.dp)) { + Text( + label, + style = MusFitTheme.typography.labelMedium.copy(fontSize = 12.5.sp, fontWeight = FontWeight.W800), + color = color, + ) } } } @@ -530,10 +565,11 @@ private fun liveRecommendedTargets( private fun Double.roundToPaceStep(): Double = Math.round(this * 10.0) / 10.0 /** The sheet renames Maintain to the mock's "Keep"; storage is unchanged. */ +@Composable private fun GoalType.sheetLabel(): String = when (this) { - GoalType.Lose -> "Lose" - GoalType.Maintain -> "Keep" - GoalType.Gain -> "Gain" + GoalType.Lose -> stringResource(R.string.profile_goal_lose) + GoalType.Maintain -> stringResource(R.string.profile_goal_keep) + GoalType.Gain -> stringResource(R.string.profile_goal_gain) } private fun GoalType.trendIcon(): ImageVector = when (this) { @@ -552,12 +588,12 @@ fun LogWeightDialog( val parsed = text.toPositiveDoubleOrNull() AlertDialog( onDismissRequest = onDismiss, - title = { Text("Log weight") }, - text = { NumberField(value = text, onValueChange = { text = it }, label = "Weight (kg)") }, + title = { Text(stringResource(R.string.profile_log_weight)) }, + text = { NumberField(value = text, onValueChange = { text = it }, label = stringResource(R.string.profile_weight_kg)) }, confirmButton = { - TextButton(enabled = parsed != null, onClick = { parsed?.let(onConfirm) }) { Text("Save") } + TextButton(enabled = parsed != null, onClick = { parsed?.let(onConfirm) }) { Text(stringResource(R.string.profile_save)) } }, - dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.profile_cancel)) } }, ) } @@ -574,24 +610,30 @@ fun LogMeasurementDialog( val unit = if (type == "body_fat") "%" else "cm" AlertDialog( onDismissRequest = onDismiss, - title = { Text("Log measurement") }, + title = { Text(stringResource(R.string.profile_log_measurement)) }, text = { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { ChipGroup( options = MEASUREMENT_TYPES, isSelected = { it == type }, onSelect = { type = it }, - label = { MEASUREMENT_LABELS[it] ?: it }, + label = { typeKey -> + MEASUREMENT_LABEL_RESOURCES[typeKey]?.let { stringResource(it) } ?: typeKey + }, + ) + NumberField( + value = text, + onValueChange = { text = it }, + label = stringResource(R.string.profile_value_with_unit, unit), ) - NumberField(value = text, onValueChange = { text = it }, label = "Value ($unit)") } }, confirmButton = { TextButton(enabled = parsed != null, onClick = { parsed?.let { onConfirm(type, it, unit) } }) { - Text("Save") + Text(stringResource(R.string.profile_save)) } }, - dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.profile_cancel)) } }, ) } @@ -600,7 +642,7 @@ private fun ChipGroup( options: List, isSelected: (T) -> Boolean, onSelect: (T) -> Unit, - label: (T) -> String, + label: @Composable (T) -> String, ) { Row( modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), diff --git a/feature/profile/src/main/java/com/musfit/ui/profile/ProfileExpressiveUi.kt b/feature/profile/src/main/java/com/musfit/ui/profile/ProfileExpressiveUi.kt index 277fa7c5..1730c790 100644 --- a/feature/profile/src/main/java/com/musfit/ui/profile/ProfileExpressiveUi.kt +++ b/feature/profile/src/main/java/com/musfit/ui/profile/ProfileExpressiveUi.kt @@ -59,6 +59,7 @@ import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription @@ -69,6 +70,7 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.musfit.feature.profile.R import com.musfit.ui.theme.BrandCoral import com.musfit.ui.theme.MusFitMotion import com.musfit.ui.theme.MusFitTheme @@ -349,15 +351,16 @@ internal fun ProfileCancelAction( onClick: () -> Unit, modifier: Modifier = Modifier, ) { + val cancelLabel = stringResource(R.string.profile_cancel) Box( contentAlignment = Alignment.Center, modifier = modifier .sizeIn(minWidth = 48.dp, minHeight = 48.dp) .clip(RoundedCornerShape(99.dp)) - .clickable(onClickLabel = "Cancel", role = Role.Button, onClick = onClick), + .clickable(onClickLabel = cancelLabel, role = Role.Button, onClick = onClick), ) { Text( - "Cancel", + cancelLabel, style = MusFitTheme.typography.labelLarge.copy(fontSize = 13.sp, fontWeight = FontWeight.W800), color = MusFitTheme.colors.onSurfaceVariant, ) @@ -406,7 +409,7 @@ internal fun CoachChatMark( internal fun ConnectedSegmentRow( options: List, selected: T?, - label: (T) -> String, + label: @Composable (T) -> String, accent: TabAccent, onSelect: (T) -> Unit, modifier: Modifier = Modifier, diff --git a/feature/profile/src/main/java/com/musfit/ui/profile/ProfileHistoryUi.kt b/feature/profile/src/main/java/com/musfit/ui/profile/ProfileHistoryUi.kt index e939e5df..60f4b12f 100644 --- a/feature/profile/src/main/java/com/musfit/ui/profile/ProfileHistoryUi.kt +++ b/feature/profile/src/main/java/com/musfit/ui/profile/ProfileHistoryUi.kt @@ -1,5 +1,6 @@ package com.musfit.ui.profile +import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -36,6 +37,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics @@ -46,6 +49,7 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.musfit.feature.profile.R import com.musfit.ui.components.InnerScreenTitleStyle import com.musfit.ui.components.TonalHeaderIconButton import com.musfit.ui.components.TonalIconSquare @@ -67,19 +71,24 @@ data class HistoryEntry( val unit: String, ) -internal enum class HistoryRange(val chipLabel: String, val title: String, val days: Long?) { - Week("7d", "7 days", 7L), - Month("30d", "30 days", 30L), - Quarter("90d", "90 days", 90L), - All("All", "All time", null), +internal enum class HistoryRange( + @StringRes val chipLabelResource: Int, + @StringRes val titleResource: Int, + val days: Long?, +) { + Week(R.string.profile_range_7_days_short, R.string.profile_range_7_days, 7L), + Month(R.string.profile_range_30_days_short, R.string.profile_range_30_days, 30L), + Quarter(R.string.profile_range_90_days_short, R.string.profile_range_90_days, 90L), + All(R.string.profile_range_all, R.string.profile_range_all_time, null), } -private val ENTRY_DATE_FORMAT = DateTimeFormatter.ofPattern("EEE d MMM · HH:mm", Locale.ENGLISH) -private val AXIS_DATE_FORMAT = DateTimeFormatter.ofPattern("d MMM", Locale.ENGLISH) - -private fun formatEntryTimestamp(epochMillis: Long): String = Instant.ofEpochMilli(epochMillis).atZone(ZoneId.systemDefault()).format(ENTRY_DATE_FORMAT) +private fun formatEntryTimestamp(epochMillis: Long, locale: Locale): String = Instant.ofEpochMilli(epochMillis) + .atZone(ZoneId.systemDefault()) + .format(DateTimeFormatter.ofPattern("EEE d MMM · HH:mm", locale)) -private fun formatAxisDate(epochMillis: Long): String = Instant.ofEpochMilli(epochMillis).atZone(ZoneId.systemDefault()).format(AXIS_DATE_FORMAT) +private fun formatAxisDate(epochMillis: Long, locale: Locale): String = Instant.ofEpochMilli(epochMillis) + .atZone(ZoneId.systemDefault()) + .format(DateTimeFormatter.ofPattern("d MMM", locale)) internal fun historyRangeStartEpochMillis( range: HistoryRange, @@ -149,7 +158,7 @@ fun MeasurementHistoryScreen( ConnectedSegmentRow( options = HistoryRange.entries, selected = range, - label = { it.chipLabel }, + label = { stringResource(it.chipLabelResource) }, accent = accent, onSelect = { range = it }, equalWidths = true, @@ -157,17 +166,17 @@ fun MeasurementHistoryScreen( HistoryChartCard(inRange = inRange, range = range, unit = unit, accent = accent, goalValue = goalValue) - GroupLabel("Entries") + GroupLabel(stringResource(R.string.profile_entries)) if (entries.isEmpty()) { Text( - "No entries yet. Log one with the + button.", + stringResource(R.string.profile_no_entries_yet), style = MusFitTheme.typography.bodyMedium, color = MusFitTheme.colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 4.dp), ) } else if (inRange.isEmpty()) { Text( - "No entries in this range.", + stringResource(R.string.profile_no_entries_in_range), style = MusFitTheme.typography.bodyMedium, color = MusFitTheme.colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 4.dp), @@ -204,17 +213,27 @@ fun MeasurementHistoryScreen( ) } deleting?.let { entry -> + val locale = LocalConfiguration.current.locales[0] AlertDialog( onDismissRequest = { deleting = null }, - title = { Text("Delete entry?") }, - text = { Text("${entry.value.format1()} ${entry.unit} · ${formatEntryTimestamp(entry.measuredAtEpochMillis)}") }, + title = { Text(stringResource(R.string.profile_delete_entry_question)) }, + text = { + Text( + stringResource( + R.string.profile_history_entry_summary, + entry.value.format1(locale), + entry.unit, + formatEntryTimestamp(entry.measuredAtEpochMillis, locale), + ), + ) + }, confirmButton = { TextButton(onClick = { onDelete(entry.id) deleting = null - }) { Text("Delete") } + }) { Text(stringResource(R.string.profile_delete)) } }, - dismissButton = { TextButton(onClick = { deleting = null }) { Text("Cancel") } }, + dismissButton = { TextButton(onClick = { deleting = null }) { Text(stringResource(R.string.profile_cancel)) } }, ) } } @@ -234,6 +253,9 @@ private fun HistoryHeader( onBack: () -> Unit, onAdd: () -> Unit, ) { + val locale = LocalConfiguration.current.locales[0] + val separator = stringResource(R.string.profile_separator_middle_dot) + val rangeTitle = stringResource(range.titleResource) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), @@ -241,7 +263,7 @@ private fun HistoryHeader( ) { TonalHeaderIconButton( icon = Icons.AutoMirrored.Outlined.ArrowBack, - contentDescription = "Back", + contentDescription = stringResource(R.string.profile_back), onClick = onBack, ) Column(modifier = Modifier.weight(1f)) { @@ -252,10 +274,11 @@ private fun HistoryHeader( Text( buildAnnotatedString { withStyle(SpanStyle(fontWeight = FontWeight.W800, color = MusFitTheme.colors.onSurface)) { - append("${latest.value.format1()} $unit") + append("${latest.value.format1(locale)} $unit") } - rangeDelta?.let { append(" · ${it.signedFormat1()} $unit") } - append(" · ${range.title.lowercase(Locale.ENGLISH)}") + rangeDelta?.let { append("$separator${it.signedFormat1(locale)} $unit") } + append(separator) + append(rangeTitle) }, style = MusFitTheme.typography.bodySmall, color = MusFitTheme.colors.onSurfaceVariant, @@ -264,7 +287,7 @@ private fun HistoryHeader( } TonalIconSquare( icon = Icons.Outlined.Add, - contentDescription = "Log entry", + contentDescription = stringResource(R.string.profile_log_entry), onClick = onAdd, size = 44.dp, cornerRadius = 16.dp, @@ -282,6 +305,7 @@ private fun HistoryChartCard( accent: TabAccent, goalValue: Double?, ) { + val locale = LocalConfiguration.current.locales[0] Surface(color = MusFitTheme.colors.surface, shape = RoundedCornerShape(24.dp), modifier = Modifier.fillMaxWidth()) { Column( modifier = Modifier.padding(horizontal = 18.dp, vertical = 16.dp), @@ -293,14 +317,19 @@ private fun HistoryChartCard( verticalAlignment = Alignment.Bottom, ) { Text( - range.title, + stringResource(range.titleResource), style = MusFitTheme.typography.titleSmall.copy(fontSize = 15.sp, fontWeight = FontWeight.W800), ) if (inRange.size >= 2) { val min = inRange.minOf { it.value } val max = inRange.maxOf { it.value } Text( - "${min.format1()} – ${max.format1()} $unit", + stringResource( + R.string.profile_history_value_range, + min.format1(locale), + max.format1(locale), + unit, + ), style = MusFitTheme.typography.bodySmall, color = MusFitTheme.colors.onSurfaceVariant, ) @@ -316,26 +345,26 @@ private fun HistoryChartCard( ) Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Text( - formatAxisDate(inRange.last().measuredAtEpochMillis), + formatAxisDate(inRange.last().measuredAtEpochMillis, locale), style = MusFitTheme.typography.labelSmall.copy(fontWeight = FontWeight.Normal, letterSpacing = 0.sp), color = MusFitTheme.colors.onSurfaceFaint, ) if (goalValue != null) { Text( - "goal ${goalValue.format1()} $unit", + stringResource(R.string.profile_goal_value_with_unit, goalValue.format1(locale), unit), style = MusFitTheme.typography.labelSmall.copy(fontWeight = FontWeight.Normal, letterSpacing = 0.sp), color = MusFitTheme.colors.onSurfaceFaint, ) } Text( - formatAxisDate(inRange.first().measuredAtEpochMillis), + formatAxisDate(inRange.first().measuredAtEpochMillis, locale), style = MusFitTheme.typography.labelSmall.copy(fontWeight = FontWeight.Normal, letterSpacing = 0.sp), color = MusFitTheme.colors.onSurfaceFaint, ) } } else { Text( - "Log at least two entries in this range to see a trend.", + stringResource(R.string.profile_log_two_entries_for_trend), style = MusFitTheme.typography.bodySmall, color = MusFitTheme.colors.onSurfaceVariant, ) @@ -361,6 +390,8 @@ private fun HistoryEntryRow( onDelete: () -> Unit, ) { var menuOpen by remember { mutableStateOf(false) } + val locale = LocalConfiguration.current.locales[0] + val entryOptions = stringResource(R.string.profile_entry_options) Surface(color = MusFitTheme.colors.surface, shape = shape, modifier = Modifier.fillMaxWidth()) { Row( modifier = Modifier.padding(start = 18.dp, end = 10.dp, top = 13.dp, bottom = 13.dp), @@ -370,7 +401,7 @@ private fun HistoryEntryRow( Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) { Row(verticalAlignment = Alignment.Bottom) { Text( - entry.value.format1(), + entry.value.format1(locale), style = MusFitTheme.typography.titleSmall.copy(fontSize = 15.5.sp, fontWeight = FontWeight.W800), ) Text( @@ -382,7 +413,7 @@ private fun HistoryEntryRow( ) } Text( - formatEntryTimestamp(entry.measuredAtEpochMillis), + formatEntryTimestamp(entry.measuredAtEpochMillis, locale), style = MusFitTheme.typography.bodySmall.copy(fontSize = 12.sp), color = MusFitTheme.colors.onSurfaceVariant, ) @@ -390,7 +421,7 @@ private fun HistoryEntryRow( if (previousValue != null) { val delta = entry.value - previousValue Text( - delta.signedFormat1(), + delta.signedFormat1(locale), style = MusFitTheme.typography.labelMedium.copy(fontSize = 12.5.sp, fontWeight = FontWeight.W800), color = deltaColor(delta, previousValue, goalValue, accent), ) @@ -400,10 +431,10 @@ private fun HistoryEntryRow( contentAlignment = Alignment.Center, modifier = Modifier .size(48.dp) - .semantics { contentDescription = "Entry options" } + .semantics { contentDescription = entryOptions } .clip(RoundedCornerShape(99.dp)) .clickable( - onClickLabel = "Entry options", + onClickLabel = entryOptions, role = Role.Button, onClick = { menuOpen = true }, ), @@ -425,14 +456,14 @@ private fun HistoryEntryRow( } DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { DropdownMenuItem( - text = { Text("Edit value") }, + text = { Text(stringResource(R.string.profile_edit_value)) }, onClick = { menuOpen = false onEdit() }, ) DropdownMenuItem( - text = { Text("Delete") }, + text = { Text(stringResource(R.string.profile_delete)) }, onClick = { menuOpen = false onDelete() @@ -467,30 +498,31 @@ private fun EditHistoryValueDialog( onDismiss: () -> Unit, onConfirm: (Double) -> Unit, ) { - var text by remember(entry.id) { mutableStateOf(entry.value.format1()) } + val locale = LocalConfiguration.current.locales[0] + var text by remember(entry.id, locale) { mutableStateOf(entry.value.format1(locale)) } val parsed = text.toPositiveDoubleOrNull() AlertDialog( onDismissRequest = onDismiss, - title = { Text("Edit value") }, + title = { Text(stringResource(R.string.profile_edit_value)) }, text = { OutlinedTextField( value = text, onValueChange = { text = it }, - label = { Text("Value (${entry.unit})") }, + label = { Text(stringResource(R.string.profile_value_with_unit, entry.unit)) }, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), modifier = Modifier.fillMaxWidth(), ) }, confirmButton = { - TextButton(enabled = parsed != null, onClick = { parsed?.let(onConfirm) }) { Text("Save") } + TextButton(enabled = parsed != null, onClick = { parsed?.let(onConfirm) }) { Text(stringResource(R.string.profile_save)) } }, - dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.profile_cancel)) } }, ) } -internal fun Double.signedFormat1(): String = when { - this > 0 -> "+${format1()}" - this < 0 -> "−${abs(this).format1()}" - else -> "0.0" +internal fun Double.signedFormat1(locale: Locale = Locale.getDefault()): String = when { + this > 0 -> "+${format1(locale)}" + this < 0 -> "−${abs(this).format1(locale)}" + else -> format1(locale) } diff --git a/feature/profile/src/main/java/com/musfit/ui/profile/ProfileScreen.kt b/feature/profile/src/main/java/com/musfit/ui/profile/ProfileScreen.kt index 706f25e7..e2b0eb76 100644 --- a/feature/profile/src/main/java/com/musfit/ui/profile/ProfileScreen.kt +++ b/feature/profile/src/main/java/com/musfit/ui/profile/ProfileScreen.kt @@ -41,11 +41,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -53,6 +52,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.musfit.feature.profile.R import com.musfit.ui.components.ExpressiveBadge import com.musfit.ui.components.ExpressiveBadgeShape import com.musfit.ui.components.MusFitScreenHeader @@ -60,6 +60,9 @@ import com.musfit.ui.components.SectionHeader import com.musfit.ui.components.TonalHeaderIconButton import com.musfit.ui.components.gridGroupShape import com.musfit.ui.components.groupedShape +import com.musfit.ui.text.LocalizedFormatter +import com.musfit.ui.text.UiText +import com.musfit.ui.text.asString import com.musfit.ui.theme.LavenderBody import com.musfit.ui.theme.LavenderBodyDark import com.musfit.ui.theme.LavenderContainer @@ -109,8 +112,9 @@ fun ProfileScreen( BackHandler(enabled = historyKey != null) { historyKey = null } - LaunchedEffect(state.message) { - val message = state.message + val resolvedMessage = state.message?.asString() + LaunchedEffect(state.message, resolvedMessage) { + val message = resolvedMessage if (message != null) { snackbarHostState.showSnackbar(message) viewModel.dismissMessage() @@ -134,8 +138,15 @@ fun ProfileScreen( HistoryEntry(it.id, it.measuredAtEpochMillis, it.value, it.unit) } } + val measurementLabel = MEASUREMENT_LABEL_RESOURCES[openHistoryKey] + ?.let { stringResource(it) } + ?: openHistoryKey MeasurementHistoryScreen( - title = if (isWeight) "Weight history" else "${MEASUREMENT_SHEET_LABELS[openHistoryKey] ?: openHistoryKey} history", + title = if (isWeight) { + stringResource(R.string.profile_weight_history) + } else { + stringResource(R.string.profile_measurement_history, measurementLabel) + }, entries = entries, accent = accent, onBack = { historyKey = null }, @@ -157,11 +168,11 @@ fun ProfileScreen( verticalArrangement = Arrangement.spacedBy(14.dp), ) { MusFitScreenHeader( - title = "Profile", + title = stringResource(R.string.profile_title), actions = { TonalHeaderIconButton( icon = Icons.Outlined.Settings, - contentDescription = "Settings", + contentDescription = stringResource(R.string.profile_settings), onClick = onSettingsClick, ) }, @@ -176,8 +187,8 @@ fun ProfileScreen( onLogWeight = { showLogWeight = true }, ) SectionHeader( - title = "Measurements", - trailingActionLabel = "+ Log", + title = stringResource(R.string.profile_measurements), + trailingActionLabel = stringResource(R.string.profile_log_action), trailingActionColor = accent.color, onTrailingAction = { showLogMeasurement = true }, ) @@ -190,7 +201,7 @@ fun ProfileScreen( } }, ) - SectionHeader(title = "Plans & progress") + SectionHeader(title = stringResource(R.string.profile_plans_and_progress)) PlansAndProgressList( plansSummary = state.plansSummary, onOpenFood = onOpenFood, @@ -248,7 +259,7 @@ private fun HealthConnectNudge(onOpen: () -> Unit) { // Clip before clickable so the ripple stays inside the rounded shape. .clip(shape) .clickable( - onClickLabel = "Open Health Connect settings", + onClickLabel = stringResource(R.string.profile_open_health_connect_settings), role = Role.Button, onClick = onOpen, ), @@ -259,13 +270,13 @@ private fun HealthConnectNudge(onOpen: () -> Unit) { horizontalArrangement = Arrangement.spacedBy(12.dp), ) { Text( - "Connect Health Connect to mirror steps and heart rate", + stringResource(R.string.profile_health_connect_nudge), style = MusFitTheme.typography.bodySmall, color = body, modifier = Modifier.weight(1f), ) Text( - "Set up", + stringResource(R.string.profile_set_up), style = MusFitTheme.typography.labelMedium, fontWeight = FontWeight.ExtraBold, color = action, @@ -297,7 +308,7 @@ private fun WeightHeroCard( .fillMaxWidth() .clip(shape) .clickable( - onClickLabel = "Open weight history", + onClickLabel = stringResource(R.string.profile_open_weight_history), role = Role.Button, onClick = onOpenEntries, ), @@ -312,13 +323,13 @@ private fun WeightHeroCard( Row(verticalAlignment = Alignment.Bottom) { // hasAnyEntry ⇔ latestWeightKg != null by construction (both from the same series). Text( - hero.latestWeightKg!!.format1(), + hero.latestWeightKg!!.format1(LocalConfiguration.current.locales[0]), style = MusFitTheme.typography.displayLarge.copy(fontSize = 52.sp, lineHeight = 52.sp), color = accent.onContainer, maxLines = 1, ) Text( - "kg", + stringResource(R.string.profile_unit_kg), style = MusFitTheme.typography.titleLarge.copy(fontSize = 19.sp), fontWeight = FontWeight.Medium, color = accent.onContainer, @@ -342,43 +353,43 @@ private fun WeightHeroCard( hero.chartSeries.isEmpty() -> // entries exist (outer branch) but none in the window Text( - "No entries in the last 30 days.", + stringResource(R.string.profile_no_entries_30_days), style = MusFitTheme.typography.bodySmall, color = accent.onContainerVariant, ) else -> // exactly one point in the window — a chart or "no entries" text would both mislead Text( - "Log again to see a trend.", + stringResource(R.string.profile_log_again_for_trend), style = MusFitTheme.typography.bodySmall, color = accent.onContainerVariant, ) } } else { - Text("No weight logged yet.", style = MusFitTheme.typography.bodyMedium, color = accent.onContainerVariant) + Text( + stringResource(R.string.profile_no_weight_logged), + style = MusFitTheme.typography.bodyMedium, + color = accent.onContainerVariant, + ) } Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Text( text = if (hero.hasAnyEntry) { - buildAnnotatedString { - append("30 days") - hero.goalProgressFraction?.let { - append(" · ") - withStyle(SpanStyle(fontWeight = FontWeight.W800)) { - append("${(it * 100).roundToInt()}%") - } - append(" to goal") - } - } + hero.goalProgressFraction?.let { + stringResource( + R.string.profile_30_days_to_goal, + LocalizedFormatter.integer((it * 100).roundToInt().toLong(), locale = LocalConfiguration.current.locales[0]), + ) + } ?: stringResource(R.string.profile_30_days) } else { - buildAnnotatedString {} + "" }, style = MusFitTheme.typography.bodySmall.copy(fontSize = 12.sp), color = accent.onContainerVariant, modifier = Modifier.weight(1f), ) HeroActionPill( - text = "Log weight", + text = stringResource(R.string.profile_log_weight), icon = Icons.Outlined.Add, accent = accent, onClick = onLogWeight, @@ -392,17 +403,21 @@ private fun WeightHeroCard( @Composable private fun WeightMetaLine(hero: WeightHeroState, accent: TabAccent) { val delta = hero.deltaKg - val meta = buildAnnotatedString { - if (delta != null) { - withStyle(SpanStyle(fontWeight = FontWeight.W800)) { - append("${if (delta < 0) "−" else "+"}${abs(delta).format1()} kg") - } - append(" · 7 days") - } - hero.bmi?.let { - if (length > 0) append(" · ") - append("BMI ${it.format1()}") - } + val locale = LocalConfiguration.current.locales[0] + val deltaText = delta?.let { + stringResource( + R.string.profile_weight_delta_7_days, + stringResource(if (it < 0) R.string.profile_sign_minus else R.string.profile_sign_plus), + abs(it).format1(locale), + ) + } + val bmiText = hero.bmi?.let { + stringResource(R.string.profile_bmi, it.format1(locale)) + } + val meta = when { + deltaText != null && bmiText != null -> stringResource(R.string.profile_join_middle_dot, deltaText, bmiText) + deltaText != null -> deltaText + else -> bmiText.orEmpty() } if (meta.isNotEmpty()) { Text( @@ -415,20 +430,29 @@ private fun WeightMetaLine(hero: WeightHeroState, accent: TabAccent) { } /** "goal 82 kg · gain 0.3/wk" — the goal chip carries the pace since Turn 11. */ +@Composable private fun goalChipText(state: ProfileUiState): String { val goalWeight = state.hero.goalWeightKg ?: return "" val profile = state.profile + val locale = LocalConfiguration.current.locales[0] val paceText = profile?.let { when (it.goalType) { - com.musfit.domain.profile.GoalType.Maintain -> "maintain" - com.musfit.domain.profile.GoalType.Lose -> "lose ${it.goalPaceKgPerWeek.format1()}/wk" - com.musfit.domain.profile.GoalType.Gain -> "gain ${it.goalPaceKgPerWeek.format1()}/wk" + com.musfit.domain.profile.GoalType.Maintain -> stringResource(R.string.profile_pace_maintain) + + com.musfit.domain.profile.GoalType.Lose -> stringResource( + R.string.profile_pace_lose, + it.goalPaceKgPerWeek.format1(locale), + ) + + com.musfit.domain.profile.GoalType.Gain -> stringResource( + R.string.profile_pace_gain, + it.goalPaceKgPerWeek.format1(locale), + ) } } - return buildString { - append("goal ${goalWeight.format1()} kg") - paceText?.let { append(" · $it") } - } + val goal = goalWeight.format1(locale) + return paceText?.let { stringResource(R.string.profile_goal_weight_with_pace, goal, it) } + ?: stringResource(R.string.profile_goal_weight, goal) } /** @@ -468,13 +492,17 @@ private fun MeasurementCell( modifier: Modifier = Modifier, ) { val empty = tile.entryCount == 0 + val label = tile.label.asString() Surface( color = MusFitTheme.colors.surface, shape = shape, modifier = modifier .clip(shape) .clickable( - onClickLabel = if (empty) "Log ${tile.label}" else "Open ${tile.label} history", + onClickLabel = stringResource( + if (empty) R.string.profile_log_measurement_accessibility else R.string.profile_open_measurement_history, + label, + ), role = Role.Button, onClick = onClick, ), @@ -484,7 +512,7 @@ private fun MeasurementCell( verticalArrangement = Arrangement.spacedBy(2.dp), ) { Text( - tile.label, + label, style = MusFitTheme.typography.labelMedium.copy(fontSize = 11.sp), fontWeight = FontWeight.SemiBold, color = MusFitTheme.colors.onSurfaceVariant, @@ -492,37 +520,42 @@ private fun MeasurementCell( ) if (empty) { Text( - "Tap to log", + stringResource(R.string.profile_tap_to_log), style = MusFitTheme.typography.bodyLarge.copy(fontSize = 14.sp), fontWeight = FontWeight.Medium, color = MusFitTheme.colors.onSurfaceFaint, maxLines = 1, ) } else { - Row { - Text( - tile.value!!.format1(), - style = MusFitTheme.typography.titleLarge.copy(fontSize = 18.sp, letterSpacing = (-0.4).sp), - fontWeight = FontWeight.ExtraBold, - color = MusFitTheme.colors.onSurface, - maxLines = 1, - modifier = Modifier.alignByBaseline(), - ) - Text( - tile.unit, - style = MusFitTheme.typography.bodySmall.copy(fontSize = 11.sp), - fontWeight = FontWeight.Medium, - color = MusFitTheme.colors.onSurfaceVariant, - maxLines = 1, - modifier = Modifier.alignByBaseline().padding(start = 2.dp), - ) - } + MeasurementValue(tile.value!!, tile.unit) MeasurementTrendRow(delta = tile.delta30d, accent = accent) } } } } +@Composable +private fun MeasurementValue(value: Double, unit: String) { + Row { + Text( + value.format1(LocalConfiguration.current.locales[0]), + style = MusFitTheme.typography.titleLarge.copy(fontSize = 18.sp, letterSpacing = (-0.4).sp), + fontWeight = FontWeight.ExtraBold, + color = MusFitTheme.colors.onSurface, + maxLines = 1, + modifier = Modifier.alignByBaseline(), + ) + Text( + unit, + style = MusFitTheme.typography.bodySmall.copy(fontSize = 11.sp), + fontWeight = FontWeight.Medium, + color = MusFitTheme.colors.onSurfaceVariant, + maxLines = 1, + modifier = Modifier.alignByBaseline().padding(start = 2.dp), + ) + } +} + /** * The 30-day trend row: direction icon + delta in the Profile teal for a * meaningful change, gray trending_flat with "flat" when the value is @@ -545,7 +578,12 @@ private fun MeasurementTrendRow(delta: Double?, accent: TabAccent) { ) { Icon(icon, contentDescription = null, tint = color, modifier = Modifier.size(13.dp)) Text( - text = if (flat) "flat" else "${if (delta < 0) "−" else "+"}${abs(delta).format1()}", + text = if (flat) { + stringResource(R.string.profile_flat) + } else { + stringResource(if (delta < 0) R.string.profile_sign_minus else R.string.profile_sign_plus) + + abs(delta).format1(LocalConfiguration.current.locales[0]) + }, style = MusFitTheme.typography.labelMedium.copy(fontSize = 11.sp), fontWeight = FontWeight.Bold, color = color, @@ -562,7 +600,7 @@ private fun MeasurementTrendRow(delta: Double?, accent: TabAccent) { */ @Composable private fun PlansAndProgressList( - plansSummary: String, + plansSummary: UiText?, onOpenFood: () -> Unit, onOpenTrainingProgress: () -> Unit, onOpenNutritionTrends: () -> Unit, @@ -572,11 +610,11 @@ private fun PlansAndProgressList( val foodAccent = tabAccentFor(TabAccentRole.Food) Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { ProfileHubRow( - title = "Goals & programs", - subtitle = plansSummary.ifBlank { "Set your goal, diet, and program" }, + title = stringResource(R.string.profile_goals_and_programs), + subtitle = plansSummary?.asString() ?: stringResource(R.string.profile_goals_and_programs_empty), shape = groupedShape(0, 3), onClick = onOpenFood, - onClickLabel = "Open goals and programs in Food", + onClickLabel = stringResource(R.string.profile_open_goals_food), leading = { ExpressiveBadge( icon = Icons.Outlined.Flag, @@ -589,8 +627,8 @@ private fun PlansAndProgressList( }, ) ProfileHubRow( - title = "Training progress", - subtitle = "PRs, trends and volume per exercise", + title = stringResource(R.string.profile_training_progress), + subtitle = stringResource(R.string.profile_training_progress_summary), shape = groupedShape(1, 3), onClick = onOpenTrainingProgress, leading = { @@ -605,8 +643,8 @@ private fun PlansAndProgressList( }, ) ProfileHubRow( - title = "Nutrition trends", - subtitle = "Weekly score · 7 and 28-day progress", + title = stringResource(R.string.profile_nutrition_trends), + subtitle = stringResource(R.string.profile_nutrition_trends_summary), shape = groupedShape(2, 3), onClick = onOpenNutritionTrends, leading = { @@ -623,32 +661,23 @@ private fun PlansAndProgressList( } } -internal val MEASUREMENT_SHEET_LABELS = mapOf( - "waist" to "Waist", - "chest" to "Chest", - "arms" to "Arms", - "thighs" to "Thighs", - "hips" to "Hips", - "body_fat" to "Body fat", -) - -internal fun Double.format1(): String = if (this % 1.0 == 0.0) toInt().toString() else String.format(Locale.US, "%.1f", this) +internal fun Double.format1(locale: Locale = Locale.getDefault()): String = LocalizedFormatter.number(this, maximumFractionDigits = 1, locale = locale) -internal fun com.musfit.domain.profile.ActivityLevel.label(): String = when (this) { - com.musfit.domain.profile.ActivityLevel.Sedentary -> "Sedentary" - com.musfit.domain.profile.ActivityLevel.Light -> "Light" - com.musfit.domain.profile.ActivityLevel.Moderate -> "Moderate" - com.musfit.domain.profile.ActivityLevel.Active -> "Active" - com.musfit.domain.profile.ActivityLevel.VeryActive -> "Very active" +internal fun com.musfit.domain.profile.ActivityLevel.labelResource(): Int = when (this) { + com.musfit.domain.profile.ActivityLevel.Sedentary -> R.string.profile_activity_sedentary + com.musfit.domain.profile.ActivityLevel.Light -> R.string.profile_activity_light + com.musfit.domain.profile.ActivityLevel.Moderate -> R.string.profile_activity_moderate + com.musfit.domain.profile.ActivityLevel.Active -> R.string.profile_activity_active + com.musfit.domain.profile.ActivityLevel.VeryActive -> R.string.profile_activity_very_active } -internal fun com.musfit.domain.profile.GoalType.label(): String = when (this) { - com.musfit.domain.profile.GoalType.Lose -> "Lose" - com.musfit.domain.profile.GoalType.Maintain -> "Maintain" - com.musfit.domain.profile.GoalType.Gain -> "Gain" +internal fun com.musfit.domain.profile.GoalType.labelResource(): Int = when (this) { + com.musfit.domain.profile.GoalType.Lose -> R.string.profile_goal_lose + com.musfit.domain.profile.GoalType.Maintain -> R.string.profile_goal_maintain + com.musfit.domain.profile.GoalType.Gain -> R.string.profile_goal_gain } -internal fun com.musfit.domain.profile.Sex.label(): String = when (this) { - com.musfit.domain.profile.Sex.Male -> "Male" - com.musfit.domain.profile.Sex.Female -> "Female" +internal fun com.musfit.domain.profile.Sex.labelResource(): Int = when (this) { + com.musfit.domain.profile.Sex.Male -> R.string.profile_sex_male + com.musfit.domain.profile.Sex.Female -> R.string.profile_sex_female } diff --git a/feature/profile/src/main/java/com/musfit/ui/profile/ProfileSettingsScreen.kt b/feature/profile/src/main/java/com/musfit/ui/profile/ProfileSettingsScreen.kt index 18ab2360..f3199704 100644 --- a/feature/profile/src/main/java/com/musfit/ui/profile/ProfileSettingsScreen.kt +++ b/feature/profile/src/main/java/com/musfit/ui/profile/ProfileSettingsScreen.kt @@ -40,7 +40,9 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight @@ -50,13 +52,18 @@ import androidx.core.net.toUri import androidx.health.connect.client.PermissionController import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.musfit.data.repository.AccountAuthProvider import com.musfit.data.repository.AccountErasureScope import com.musfit.data.repository.AiCoachProviderKind import com.musfit.data.repository.UserProfile +import com.musfit.feature.profile.R import com.musfit.ui.components.ExpressiveBadge import com.musfit.ui.components.ExpressiveBadgeShape import com.musfit.ui.components.InnerScreenHeader import com.musfit.ui.components.groupedShape +import com.musfit.ui.text.LocalizedFormatter +import com.musfit.ui.text.asString +import com.musfit.ui.text.uiText import com.musfit.ui.theme.MusFitTheme import com.musfit.ui.theme.TabAccent import com.musfit.ui.theme.TabAccentRole @@ -87,6 +94,8 @@ fun ProfileSettingsScreen( val state by viewModel.state.collectAsStateWithLifecycle() val accent = tabAccentFor(TabAccentRole.Profile) val context = LocalContext.current + val googleLabel = stringResource(R.string.profile_google) + val githubLabel = stringResource(R.string.profile_github) val coroutineScope = rememberCoroutineScope() var subPage by rememberSaveable { mutableStateOf(null) } var showProfileSheet by rememberSaveable { mutableStateOf(false) } @@ -163,7 +172,7 @@ fun ProfileSettingsScreen( googleWebClientId = entryConfig.googleWebClientId, ) }.onSuccess(viewModel::signInWithProvider) - .onFailure { viewModel.reportExternalSignInFailure("Google", it) } + .onFailure { viewModel.reportExternalSignInFailure(googleLabel, it) } } }, onGitHubSignIn = viewModel::signInWithGitHub, @@ -242,7 +251,7 @@ fun ProfileSettingsScreen( context.startActivity( Intent(Intent.ACTION_VIEW, githubDeviceCode.verificationUri.toUri()), ) - }.onFailure { viewModel.reportExternalSignInFailure("GitHub", it) } + }.onFailure { viewModel.reportExternalSignInFailure(githubLabel, it) } }, onDismiss = viewModel::dismissGitHubDeviceCode, ) @@ -284,6 +293,7 @@ internal fun SettingsHub( googleSignInConfigured: Boolean, versionName: String, ) { + val addBurnedDescription = stringResource(R.string.profile_add_burned_calories) val signInActions = providerSignInActions( googleConfigured = googleSignInConfigured, githubConfigured = state.isGitHubSignInConfigured, @@ -298,17 +308,17 @@ internal fun SettingsHub( verticalArrangement = Arrangement.spacedBy(14.dp), ) { InnerScreenHeader( - title = "Settings", - subtitle = "Profile, identity, coach and device sync", + title = stringResource(R.string.profile_settings_title), + subtitle = stringResource(R.string.profile_settings_subtitle), onBack = onBack, ) LocalAccountHero(account = state.account, accent = accent, onEdit = onEditAccount) - GroupLabel("Account") + GroupLabel(stringResource(R.string.profile_account)) Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { ProfileHubRow( - title = "Profile details", + title = stringResource(R.string.profile_details), subtitle = profileDetailsSummary(state.profile), shape = groupedShape(0, 4), onClick = onOpenProfileDetails, @@ -324,40 +334,40 @@ internal fun SettingsHub( }, ) ProviderRow( - title = "Google", - linked = state.account.providerLabel == "Google", + title = stringResource(R.string.profile_google), + linked = state.account.provider == AccountAuthProvider.Google, action = signInActions.google, accent = accent, shape = groupedShape(1, 4), badgeShape = ExpressiveBadgeShape.Circle, badgeIcon = Icons.Outlined.Link, - linkedSubtitle = state.account.email ?: "Linked to this local account", + linkedSubtitle = state.account.email ?: stringResource(R.string.profile_linked_local_account), onClick = onGoogleSignIn, ) ProviderRow( - title = "GitHub", - linked = state.account.providerLabel == "GitHub", + title = stringResource(R.string.profile_github), + linked = state.account.provider == AccountAuthProvider.GitHub, action = signInActions.github, accent = accent, shape = groupedShape(2, 4), badgeShape = ExpressiveBadgeShape.Squircle, badgeIcon = Icons.Outlined.Code, - linkedSubtitle = state.account.email ?: "Linked to this local account", + linkedSubtitle = state.account.email ?: stringResource(R.string.profile_linked_local_account), onClick = onGitHubSignIn, ) ProfileHubRow( - title = "Data & privacy", - subtitle = "Local storage, retention and erasure", + title = stringResource(R.string.profile_data_privacy), + subtitle = stringResource(R.string.profile_data_privacy_summary), shape = groupedShape(3, 4), onClick = onOpenDataTransfer, leading = null, ) } - GroupLabel("Connections") + GroupLabel(stringResource(R.string.profile_connections)) Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { ProfileHubRow( - title = "AI coach", + title = stringResource(R.string.profile_ai_title), subtitle = coachConnectionSummary(state), shape = groupedShape(0, 2), onClick = onOpenAiCoach, @@ -379,7 +389,7 @@ internal fun SettingsHub( }, ) ProfileHubRow( - title = "Health Connect", + title = stringResource(R.string.profile_health_connect), subtitle = healthConnectionSummary(state), shape = groupedShape(1, 2), onClick = onOpenHealthConnect, @@ -396,11 +406,11 @@ internal fun SettingsHub( ) } - GroupLabel("Preferences") + GroupLabel(stringResource(R.string.profile_preferences)) Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { ProfileHubRow( - title = "Add burned calories to budget", - subtitle = "kcal left = goal − eaten + burned", + title = stringResource(R.string.profile_add_burned_calories), + subtitle = stringResource(R.string.profile_burned_calorie_formula), shape = groupedShape(0, 5), onClick = null, leading = null, @@ -410,20 +420,24 @@ internal fun SettingsHub( onCheckedChange = onIncludeBurnedCaloriesChange, accent = accent, modifier = Modifier.semantics { - contentDescription = "Add burned calories to budget" + contentDescription = addBurnedDescription }, ) }, ) CompactValueRow( - title = "Units", - value = "Metric · kg, cm", + title = stringResource(R.string.profile_units), + value = stringResource(R.string.profile_metric_units), shape = groupedShape(1, 5), ) - CompactValueRow(title = "Theme", value = "System", shape = groupedShape(2, 5)) + CompactValueRow( + title = stringResource(R.string.profile_theme), + value = stringResource(R.string.profile_system), + shape = groupedShape(2, 5), + ) ProfileHubRow( - title = "Data transfer", - subtitle = "Encrypted export and restore", + title = stringResource(R.string.profile_data_transfer), + subtitle = stringResource(R.string.profile_data_transfer_summary), shape = groupedShape(3, 5), onClick = onOpenDataTransfer, leading = { @@ -437,12 +451,16 @@ internal fun SettingsHub( ) }, ) - CompactValueRow(title = "Data", value = "Local first", shape = groupedShape(4, 5)) + CompactValueRow( + title = stringResource(R.string.profile_data), + value = stringResource(R.string.profile_local_first), + shape = groupedShape(4, 5), + ) } - GroupLabel("About") + GroupLabel(stringResource(R.string.profile_about)) CompactValueRow( - title = "MusFit", + title = stringResource(R.string.profile_app_name), value = versionName, shape = RoundedCornerShape(24.dp), ) @@ -466,13 +484,17 @@ private fun ProviderRow( title = title, subtitle = when { linked -> linkedSubtitle - action.statusLabel == "Setup needed" -> "Not configured in this build" - action.statusLabel == "In progress" || action.statusLabel == "Waiting" -> action.supportingText - else -> "Optional linking · no cloud sync" + + action.statusLabel == uiText(R.string.profile_setup_needed) -> stringResource(R.string.profile_not_configured_build) + + action.statusLabel == uiText(R.string.profile_in_progress) || + action.statusLabel == uiText(R.string.profile_waiting) -> action.supportingText.asString() + + else -> stringResource(R.string.profile_optional_linking) }, shape = shape, onClick = if (!linked && action.enabled) onClick else null, - onClickLabel = action.buttonLabel, + onClickLabel = action.buttonLabel.asString(), leading = { ExpressiveBadge( icon = badgeIcon, @@ -487,7 +509,7 @@ private fun ProviderRow( when { linked -> Surface(color = accent.container, shape = RoundedCornerShape(99.dp)) { Text( - "Linked", + stringResource(R.string.profile_linked), style = MusFitTheme.typography.labelSmall.copy(fontWeight = FontWeight.W800, letterSpacing = 0.sp), color = accent.onContainer, modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), @@ -495,7 +517,13 @@ private fun ProviderRow( } else -> Text( - text = if (action.statusLabel == "In progress") "Waiting…" else "Link", + text = stringResource( + if (action.statusLabel == uiText(R.string.profile_in_progress)) { + R.string.profile_waiting_ellipsis + } else { + R.string.profile_link + }, + ), style = MusFitTheme.typography.labelLarge.copy(fontSize = 13.sp, fontWeight = FontWeight.W800), color = if (action.enabled) accent.color else MusFitTheme.colors.onSurfaceFaint, ) @@ -505,36 +533,73 @@ private fun ProviderRow( } /** "31 · 183 cm · gain 0.3 kg/wk" — or an invitation while fields are missing. */ +@Composable private fun profileDetailsSummary(profile: UserProfile): String { + val locale = LocalConfiguration.current.locales[0] val age = profile.birthDateEpochDay?.let { - Period.between(LocalDate.ofEpochDay(it), LocalDate.now()).years.toString() + LocalizedFormatter.integer(Period.between(LocalDate.ofEpochDay(it), LocalDate.now()).years.toLong(), locale = locale) + } + val height = profile.heightCm?.let { + stringResource(R.string.profile_details_height, it.format1(locale)) } - val height = profile.heightCm?.let { "${it.format1()} cm" } val pace = when (profile.goalType) { - com.musfit.domain.profile.GoalType.Maintain -> "maintain" - com.musfit.domain.profile.GoalType.Lose -> "lose ${profile.goalPaceKgPerWeek.format1()} kg/wk" - com.musfit.domain.profile.GoalType.Gain -> "gain ${profile.goalPaceKgPerWeek.format1()} kg/wk" + com.musfit.domain.profile.GoalType.Maintain -> stringResource(R.string.profile_pace_maintain) + + com.musfit.domain.profile.GoalType.Lose -> stringResource( + R.string.profile_details_pace_lose, + profile.goalPaceKgPerWeek.format1(locale), + ) + + com.musfit.domain.profile.GoalType.Gain -> stringResource( + R.string.profile_details_pace_gain, + profile.goalPaceKgPerWeek.format1(locale), + ) } val parts = listOfNotNull(age, height, pace) - return if (age == null && height == null) "Add your age, height, and goal" else parts.joinToString(" · ") + return if (age == null && height == null) { + stringResource(R.string.profile_details_invitation) + } else { + when (parts.size) { + 1 -> parts.single() + + 2 -> stringResource(R.string.profile_join_middle_dot, parts[0], parts[1]) + + else -> { + val firstTwo = stringResource(R.string.profile_join_middle_dot, parts[0], parts[1]) + stringResource(R.string.profile_join_middle_dot, firstTwo, parts[2]) + } + } + } } +@Composable private fun coachConnectionSummary(state: ProfileSettingsUiState): String { val coach = state.aiCoach return when { - coach.providerKind == AiCoachProviderKind.Disabled -> "Off · bring your own model" - coach.modelName.isNotBlank() -> "${coach.providerLabel} · ${coach.modelName}" - else -> coach.providerLabel + coach.providerKind == AiCoachProviderKind.Disabled -> stringResource(R.string.profile_ai_off_summary) + + coach.modelName.isNotBlank() -> stringResource( + R.string.profile_ai_provider_model, + coach.providerLabel.asString(), + coach.modelName, + ) + + else -> coach.providerLabel.asString() } } +@Composable private fun healthConnectionSummary(state: ProfileSettingsUiState): String = when { state.requestablePermissionCount > 0 -> - "${state.grantedPermissionCount} of ${state.requestablePermissionCount} permissions" + stringResource( + R.string.profile_permissions_summary, + LocalizedFormatter.integer(state.grantedPermissionCount.toLong(), locale = LocalConfiguration.current.locales[0]), + LocalizedFormatter.integer(state.requestablePermissionCount.toLong(), locale = LocalConfiguration.current.locales[0]), + ) - state.availabilityLabel == "Unknown" -> "Checking availability…" + state.availabilityLabel == uiText(R.string.profile_unknown) -> stringResource(R.string.profile_checking_availability) - else -> state.availabilityLabel + else -> state.availabilityLabel.asString() } @Composable @@ -546,15 +611,15 @@ private fun GitHubDeviceCodeDialog( ) { AlertDialog( onDismissRequest = onDismiss, - title = { Text("GitHub sign-in") }, + title = { Text(stringResource(R.string.profile_github_sign_in)) }, text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text("Enter this code at GitHub:") + Text(stringResource(R.string.profile_github_enter_code_prompt)) Text(userCode, style = MusFitTheme.typography.headlineSmall) Text(verificationUri, style = MusFitTheme.typography.bodySmall) } }, - confirmButton = { TextButton(onClick = onOpen) { Text("Open GitHub") } }, - dismissButton = { TextButton(onClick = onDismiss) { Text("Close") } }, + confirmButton = { TextButton(onClick = onOpen) { Text(stringResource(R.string.profile_open_github)) } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.profile_close)) } }, ) } diff --git a/feature/profile/src/main/java/com/musfit/ui/profile/ProfileSettingsViewModel.kt b/feature/profile/src/main/java/com/musfit/ui/profile/ProfileSettingsViewModel.kt index 251ee549..770830d3 100644 --- a/feature/profile/src/main/java/com/musfit/ui/profile/ProfileSettingsViewModel.kt +++ b/feature/profile/src/main/java/com/musfit/ui/profile/ProfileSettingsViewModel.kt @@ -29,8 +29,14 @@ import com.musfit.data.repository.UserProfile import com.musfit.domain.health.HealthConnectAvailability import com.musfit.domain.health.StepSource import com.musfit.domain.profile.RecommendedTargets +import com.musfit.feature.profile.R +import com.musfit.ui.text.LocalizedFormatter +import com.musfit.ui.text.UiText +import com.musfit.ui.text.pluralUiText +import com.musfit.ui.text.uiText import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -43,8 +49,6 @@ import java.time.LocalDate import java.util.Locale import javax.inject.Inject -private const val DEFAULT_STATUS_MESSAGE = "Refresh status to check whether Health Connect is ready." -private const val ALL_STEP_SOURCES_LABEL = "All sources (unified)" internal const val HERMES_DEFAULT_MODEL_NAME = "hermes-agent" private const val ACCOUNT_EDITOR_OPEN_KEY = "profile.accountEditor.open" private const val ACCOUNT_EDITOR_NAME_KEY = "profile.accountEditor.name" @@ -55,10 +59,10 @@ private const val AI_COACH_EDITOR_BASE_URL_KEY = "profile.aiCoachEditor.baseUrl" private const val AI_COACH_EDITOR_MODEL_NAME_KEY = "profile.aiCoachEditor.modelName" private const val AI_COACH_EDITOR_LOCAL_AGENT_KEY = "profile.aiCoachEditor.localAgent" -private fun stepSourceLabel(preferredStepsPackage: String?, sources: List): String = if (preferredStepsPackage == null) { - ALL_STEP_SOURCES_LABEL +private fun stepSourceLabel(preferredStepsPackage: String?, sources: List): UiText = if (preferredStepsPackage == null) { + uiText(R.string.profile_all_step_sources) } else { - sources.firstOrNull { it.packageName == preferredStepsPackage }?.label ?: preferredStepsPackage + UiText.Verbatim(sources.firstOrNull { it.packageName == preferredStepsPackage }?.label ?: preferredStepsPackage) } /** Lifecycle of the coach connection test — drives the 11c hero status line. */ @@ -67,21 +71,21 @@ enum class AiCoachTestState { Idle, Testing, Success, Failure } enum class TargetApplyState { Idle, Applying, Success, Failure } data class ProfileSettingsUiState( - val availabilityLabel: String = "Unknown", + val availabilityLabel: UiText = uiText(R.string.profile_unknown), val grantedPermissionCount: Int = 0, val requestablePermissionCount: Int = 0, val requestablePermissions: Set = emptySet(), val canRequestPermissions: Boolean = false, val isHealthConnectSyncing: Boolean = false, - val message: String = DEFAULT_STATUS_MESSAGE, + val message: UiText = uiText(R.string.profile_health_status_refresh), val preferredStepsPackage: String? = null, - val stepSourceLabel: String = ALL_STEP_SOURCES_LABEL, + val stepSourceLabel: UiText = uiText(R.string.profile_all_step_sources), val stepSources: List = emptyList(), val account: AccountUiState = AccountUiState(), val accountEditorOpen: Boolean = false, val accountNameInput: String = "", val accountEmailInput: String = "", - val accountErrorMessage: String? = null, + val accountErrorMessage: UiText? = null, val accountErasureScope: AccountErasureScope? = null, val deleteAuthoredHealthRecords: Boolean = false, val isErasingAccountData: Boolean = false, @@ -97,8 +101,8 @@ data class ProfileSettingsUiState( val aiCoachModelNameInput: String = "", val aiCoachLocalAgentInput: LocalAgentKind = LocalAgentKind.Custom, val aiCoachApiKeyInput: String = "", - val aiCoachErrorMessage: String? = null, - val aiCoachMessage: String? = null, + val aiCoachErrorMessage: UiText? = null, + val aiCoachMessage: UiText? = null, val isAiCoachTesting: Boolean = false, val aiCoachTestState: AiCoachTestState = AiCoachTestState.Idle, val includeBurnedCalories: Boolean = false, @@ -112,11 +116,11 @@ data class AiCoachSettingsUiState( val modelName: String = "", val localAgentKind: LocalAgentKind = LocalAgentKind.Custom, val hasApiKey: Boolean = false, - val providerLabel: String = "Off", - val endpointLabel: String = "Not set", - val modelLabel: String = "Not set", - val localAgentLabel: String = "Custom local agent", - val apiKeyLabel: String = "No API key", + val providerLabel: UiText = uiText(R.string.profile_off), + val endpointLabel: UiText = uiText(R.string.profile_not_set), + val modelLabel: UiText = uiText(R.string.profile_not_set), + val localAgentLabel: UiText = uiText(R.string.profile_custom_local_agent), + val apiKeyLabel: UiText = uiText(R.string.profile_no_api_key), ) internal data class ProviderSignInActionsUiState( @@ -125,10 +129,10 @@ internal data class ProviderSignInActionsUiState( ) internal data class ProviderSignInActionUiState( - val providerLabel: String, - val buttonLabel: String, - val statusLabel: String, - val supportingText: String, + val providerLabel: UiText, + val buttonLabel: UiText, + val statusLabel: UiText, + val supportingText: UiText, val enabled: Boolean, ) @@ -138,32 +142,32 @@ internal fun providerSignInActions( githubBusy: Boolean, ): ProviderSignInActionsUiState = ProviderSignInActionsUiState( google = ProviderSignInActionUiState( - providerLabel = "Google", - buttonLabel = "Connect Google", + providerLabel = uiText(R.string.profile_google), + buttonLabel = uiText(R.string.profile_connect_google), statusLabel = when { - !googleConfigured -> "Setup needed" - githubBusy -> "Waiting" - else -> "Ready" + !googleConfigured -> uiText(R.string.profile_setup_needed) + githubBusy -> uiText(R.string.profile_waiting) + else -> uiText(R.string.profile_ready) }, supportingText = when { - !googleConfigured -> "Missing Google OAuth client ID in this build." - githubBusy -> "Wait for GitHub to finish first." - else -> "Links Google to this local account. MusFit still keeps your data on this device." + !googleConfigured -> uiText(R.string.profile_google_missing_client) + githubBusy -> uiText(R.string.profile_wait_github_finish) + else -> uiText(R.string.profile_google_local_link_explanation) }, enabled = googleConfigured && !githubBusy, ), github = ProviderSignInActionUiState( - providerLabel = "GitHub", - buttonLabel = if (githubBusy) "Waiting for GitHub" else "Connect GitHub", + providerLabel = uiText(R.string.profile_github), + buttonLabel = uiText(if (githubBusy) R.string.profile_waiting_github else R.string.profile_connect_github), statusLabel = when { - githubBusy -> "In progress" - !githubConfigured -> "Setup needed" - else -> "Ready" + githubBusy -> uiText(R.string.profile_in_progress) + !githubConfigured -> uiText(R.string.profile_setup_needed) + else -> uiText(R.string.profile_ready) }, supportingText = when { - githubBusy -> "Enter the code in GitHub to finish linking your local account." - !githubConfigured -> "Missing GitHub OAuth client ID in this build." - else -> "Uses GitHub device flow to link your local account." + githubBusy -> uiText(R.string.profile_github_enter_code_supporting) + !githubConfigured -> uiText(R.string.profile_github_missing_client) + else -> uiText(R.string.profile_github_device_flow_explanation) }, enabled = githubConfigured && !githubBusy, ), @@ -176,18 +180,18 @@ internal fun providerSignInActions( * future `mutableState.update { it.copy(...) }` can never silently drop them. */ private data class HealthConnectState( - val availabilityLabel: String = "Unknown", + val availabilityLabel: UiText = uiText(R.string.profile_unknown), val grantedPermissionCount: Int = 0, val requestablePermissionCount: Int = 0, val requestablePermissions: Set = emptySet(), val canRequestPermissions: Boolean = false, val isHealthConnectSyncing: Boolean = false, - val message: String = DEFAULT_STATUS_MESSAGE, + val message: UiText = uiText(R.string.profile_health_status_refresh), val stepSources: List = emptyList(), val githubDeviceCode: GitHubDeviceAuthorization? = null, val githubSignInInProgress: Boolean = false, val isGitHubSignInConfigured: Boolean = false, - val aiCoachMessage: String? = null, + val aiCoachMessage: UiText? = null, val isAiCoachTesting: Boolean = false, val aiCoachTestState: AiCoachTestState = AiCoachTestState.Idle, val targetApplyState: TargetApplyState = TargetApplyState.Idle, @@ -203,7 +207,7 @@ private data class AccountEditorState( val open: Boolean = false, val nameInput: String = "", val emailInput: String = "", - val errorMessage: String? = null, + val errorMessage: UiText? = null, ) private data class AccountErasureState( @@ -219,7 +223,7 @@ private data class AiCoachEditorState( val modelNameInput: String = "", val localAgentKind: LocalAgentKind = LocalAgentKind.Custom, val apiKeyInput: String = "", - val errorMessage: String? = null, + val errorMessage: UiText? = null, ) private fun SavedStateHandle.restoreAccountEditor(): AccountEditorState = AccountEditorState( @@ -326,13 +330,19 @@ class ProfileSettingsViewModel @Inject constructor( ) private val accountErasureFlow = MutableStateFlow(AccountErasureState()) private val editorDraftState = ProfileSettingsEditorDraftState(savedStateHandle) + private val healthActions = ProfileHealthSettingsActions( + healthRepository = healthRepository, + foodRepository = foodRepository, + mutableState = mutableState, + scope = viewModelScope, + ) init { viewModelScope.launch { runCatching { accountRepository.ensureActiveAccount() } .onFailure { error -> mutableState.update { - it.copy(message = error.message ?: "Could not prepare your local account.") + it.copy(message = error.messageOr(R.string.profile_error_prepare_account)) } } } @@ -419,161 +429,28 @@ class ProfileSettingsViewModel @Inject constructor( initialValue = ProfileSettingsUiState(), ) - fun refreshStatus() { - viewModelScope.launch { - runCatching { - val status = healthRepository.status() - val requestablePermissions = healthRepository.requestablePermissions() - val launchablePermissions = if (status.availability == HealthConnectAvailability.Available) { - requestablePermissions - } else { - emptySet() - } - mutableState.update { - it.copy( - availabilityLabel = status.availability.label(), - grantedPermissionCount = status.grantedPermissions.size, - requestablePermissionCount = requestablePermissions.size, - requestablePermissions = launchablePermissions, - canRequestPermissions = status.availability == HealthConnectAvailability.Available && - launchablePermissions.isNotEmpty(), - message = status.toMessage(requestablePermissions.size), - ) - } - loadStepSources() - }.onFailure { - mutableState.update { - it.copy( - availabilityLabel = "Unknown", - grantedPermissionCount = 0, - requestablePermissionCount = 0, - requestablePermissions = emptySet(), - canRequestPermissions = false, - message = "Unable to refresh Health Connect status right now. Try again from the Profile tab.", - ) - } - } - } - } + fun refreshStatus() = healthActions.refreshStatus() - fun importToday() { - viewModelScope.launch { - runCatching { - healthRepository.importDailySummary(LocalDate.now()) - }.onSuccess { summary -> - mutableState.update { - it.copy(message = summary.importMessage()) - } - }.onFailure { error -> - mutableState.update { - it.copy(message = error.message ?: "Unable to import from Health Connect.") - } - } - } - } + fun importToday() = healthActions.importToday() - fun syncRecentHealthData() { - viewModelScope.launch { - mutableState.update { it.copy(isHealthConnectSyncing = true) } - runCatching { - healthRepository.refreshRecentData(LocalDate.now()) - }.onSuccess { result -> - mutableState.update { - it.copy( - isHealthConnectSyncing = false, - message = result.toMessage(), - ) - } - }.onFailure { error -> - mutableState.update { - it.copy( - isHealthConnectSyncing = false, - message = error.message ?: "Unable to sync Health Connect data.", - ) - } - } - } - } + fun syncRecentHealthData() = healthActions.syncRecentHealthData() - fun exportLatestWorkout() { - viewModelScope.launch { - runCatching { - healthRepository.exportLatestWorkout() - }.onSuccess { recordId -> - mutableState.update { - it.copy( - message = if (recordId != null) { - "Exported latest workout to Health Connect." - } else { - "No workout was exported. Check permissions and log a workout first." - }, - ) - } - }.onFailure { error -> - mutableState.update { - it.copy(message = error.message ?: "Unable to export workout to Health Connect.") - } - } - } - } + fun exportLatestWorkout() = healthActions.exportLatestWorkout() - fun selectStepSource(packageName: String?) { - if (mutableState.value.isHealthConnectSyncing) return - mutableState.update { it.copy(isHealthConnectSyncing = true) } - viewModelScope.launch { - runCatching { - healthRepository.setPreferredStepsPackage(packageName) - healthRepository.refreshRecentData(LocalDate.now()) - }.onSuccess { - mutableState.update { - it.copy( - isHealthConnectSyncing = false, - message = "Updated steps source. MusFit now shows " + - "${stepSourceLabel(packageName, it.stepSources)}.", - ) - } - }.onFailure { error -> - mutableState.update { - it.copy( - isHealthConnectSyncing = false, - message = error.message ?: "Unable to update steps source.", - ) - } - } - } - } - - private fun loadStepSources() { - viewModelScope.launch { - val sources = runCatching { healthRepository.readStepSources(LocalDate.now()) } - .getOrDefault(emptyList()) - mutableState.update { it.copy(stepSources = sources) } - } - } + fun selectStepSource(packageName: String?) = healthActions.selectStepSource(packageName) /** * Master switch for the burned-calorie budget adjustment. It writes the food goal's * `includeTrainingCalories` flag — the same value the Food goal editor toggles — so both * stay in sync. When off, Health Connect burned calories are never added to "kcal left". */ - fun setIncludeBurnedCalories(enabled: Boolean) { - viewModelScope.launch { - runCatching { - val current = foodRepository.observeFoodGoal().first() - foodRepository.updateFoodGoal(current.copy(includeTrainingCalories = enabled)) - }.onFailure { error -> - mutableState.update { - it.copy(message = error.message ?: "Could not update the burned-calorie setting.") - } - } - } - } + fun setIncludeBurnedCalories(enabled: Boolean) = healthActions.setIncludeBurnedCalories(enabled) fun saveProfile(profile: UserProfile) { viewModelScope.launch { runCatching { profileRepository.saveProfile(profile) } .onFailure { error -> - mutableState.update { it.copy(message = error.message ?: "Could not save profile.") } + mutableState.update { it.copy(message = error.messageOr(R.string.profile_error_save_profile)) } } } } @@ -582,7 +459,7 @@ class ProfileSettingsViewModel @Inject constructor( viewModelScope.launch { runCatching { profileRepository.logWeight(weightKg) } .onFailure { error -> - mutableState.update { it.copy(message = error.message ?: "Could not log weight.") } + mutableState.update { it.copy(message = error.messageOr(R.string.profile_error_log_weight)) } } } } @@ -628,7 +505,7 @@ class ProfileSettingsViewModel @Inject constructor( ) }.getOrElse { error -> accountErasureFlow.update { it.copy(inProgress = false) } - mutableState.update { it.copy(message = error.message ?: "Data erasure failed.") } + mutableState.update { it.copy(message = error.messageOr(R.string.profile_error_data_erasure)) } return@launch } when (result) { @@ -636,18 +513,20 @@ class ProfileSettingsViewModel @Inject constructor( accountErasureFlow.value = AccountErasureState() mutableState.update { it.copy( - message = if (scope == AccountErasureScope.AllAccounts) { - "All local MusFit data was erased. A fresh local account is ready." - } else { - "The account and its local MusFit data were erased." - }, + message = uiText( + if (scope == AccountErasureScope.AllAccounts) { + R.string.profile_erased_all_data + } else { + R.string.profile_erased_account_data + }, + ), ) } } is AccountErasureResult.HealthCleanupFailed -> { accountErasureFlow.update { it.copy(inProgress = false) } - mutableState.update { it.copy(message = result.message) } + mutableState.update { it.copy(message = UiText.Verbatim(result.message)) } } } } @@ -664,7 +543,7 @@ class ProfileSettingsViewModel @Inject constructor( fun saveAccount() { val editor = editorDraftState.accountEditorFlow.value if (editor.nameInput.isBlank()) { - editorDraftState.updateAccount { it.copy(errorMessage = "Account name is required.") } + editorDraftState.updateAccount { it.copy(errorMessage = uiText(R.string.profile_account_name_required)) } return } viewModelScope.launch { @@ -677,7 +556,7 @@ class ProfileSettingsViewModel @Inject constructor( editorDraftState.setAccount(AccountEditorState()) }.onFailure { error -> editorDraftState.updateAccount { - it.copy(errorMessage = error.message ?: "Could not save account.") + it.copy(errorMessage = error.messageOr(R.string.profile_error_save_account)) } } } @@ -691,13 +570,18 @@ class ProfileSettingsViewModel @Inject constructor( fun reportExternalSignInFailure(providerName: String, error: Throwable) { mutableState.update { - it.copy(message = error.message ?: "Could not sign in with $providerName.") + it.copy( + message = error.message?.let(UiText::Verbatim) ?: uiText( + R.string.profile_error_sign_in_provider, + UiText.Argument.Text(providerName), + ), + ) } } fun signInWithGitHub() { if (!externalAuthRepository.isGitHubConfigured) { - mutableState.update { it.copy(message = "GitHub sign-in is not configured.") } + mutableState.update { it.copy(message = uiText(R.string.profile_github_not_configured)) } return } viewModelScope.launch { @@ -705,7 +589,7 @@ class ProfileSettingsViewModel @Inject constructor( it.copy( githubSignInInProgress = true, githubDeviceCode = null, - message = "Starting GitHub sign-in.", + message = uiText(R.string.profile_github_starting), ) } runCatching { @@ -713,7 +597,10 @@ class ProfileSettingsViewModel @Inject constructor( mutableState.update { it.copy( githubDeviceCode = authorization, - message = "Enter ${authorization.userCode} at GitHub to finish sign-in.", + message = uiText( + R.string.profile_github_enter_code, + UiText.Argument.Text(authorization.userCode), + ), ) } } @@ -723,7 +610,7 @@ class ProfileSettingsViewModel @Inject constructor( mutableState.update { it.copy( githubSignInInProgress = false, - message = error.message ?: "Could not sign in with GitHub.", + message = error.messageOr(R.string.profile_error_sign_in_github), ) } } @@ -741,7 +628,10 @@ class ProfileSettingsViewModel @Inject constructor( mutableState.update { it.copy( githubSignInInProgress = false, - message = "Signed in with ${profile.provider.messageLabel()}.", + message = uiText( + R.string.profile_signed_in_provider, + UiText.Argument.Nested(profile.provider.messageLabel()), + ), ) } editorDraftState.setAccount(AccountEditorState()) @@ -749,7 +639,7 @@ class ProfileSettingsViewModel @Inject constructor( mutableState.update { it.copy( githubSignInInProgress = false, - message = error.message ?: "Could not sign in.", + message = error.messageOr(R.string.profile_error_sign_in), ) } } @@ -845,11 +735,11 @@ class ProfileSettingsViewModel @Inject constructor( editorDraftState.setAiCoach(AiCoachEditorState()) // A previous test result no longer describes the new settings. mutableState.update { - it.copy(aiCoachMessage = "AI coach setup saved.", aiCoachTestState = AiCoachTestState.Idle) + it.copy(aiCoachMessage = uiText(R.string.profile_ai_saved), aiCoachTestState = AiCoachTestState.Idle) } }.onFailure { error -> editorDraftState.updateAiCoach { - it.copy(errorMessage = error.message ?: "Could not save AI coach setup.") + it.copy(errorMessage = error.messageOr(R.string.profile_error_save_ai)) } } } @@ -860,12 +750,12 @@ class ProfileSettingsViewModel @Inject constructor( runCatching { aiCoachRepository.clearApiKey() } .onSuccess { mutableState.update { - it.copy(aiCoachMessage = "AI coach API key cleared.", aiCoachTestState = AiCoachTestState.Idle) + it.copy(aiCoachMessage = uiText(R.string.profile_ai_key_cleared), aiCoachTestState = AiCoachTestState.Idle) } } .onFailure { error -> mutableState.update { - it.copy(aiCoachMessage = error.message ?: "Could not clear AI coach API key.") + it.copy(aiCoachMessage = error.messageOr(R.string.profile_error_clear_ai_key)) } } } @@ -873,7 +763,7 @@ class ProfileSettingsViewModel @Inject constructor( fun reportAiCoachLocalNetworkPermissionDenied(message: String) { mutableState.update { - it.copy(aiCoachMessage = message) + it.copy(aiCoachMessage = UiText.Verbatim(message)) } } @@ -883,7 +773,7 @@ class ProfileSettingsViewModel @Inject constructor( it.copy( isAiCoachTesting = true, aiCoachTestState = AiCoachTestState.Testing, - aiCoachMessage = "Testing AI coach connection...", + aiCoachMessage = uiText(R.string.profile_ai_testing), ) } runCatching { aiCoachChatRepository.testConnection() } @@ -892,7 +782,7 @@ class ProfileSettingsViewModel @Inject constructor( it.copy( isAiCoachTesting = false, aiCoachTestState = AiCoachTestState.Success, - aiCoachMessage = "AI coach connection is reachable.", + aiCoachMessage = uiText(R.string.profile_ai_reachable), ) } } @@ -901,7 +791,7 @@ class ProfileSettingsViewModel @Inject constructor( it.copy( isAiCoachTesting = false, aiCoachTestState = AiCoachTestState.Failure, - aiCoachMessage = error.message ?: "AI coach connection is not reachable.", + aiCoachMessage = error.messageOr(R.string.profile_ai_not_reachable), ) } } @@ -935,7 +825,7 @@ class ProfileSettingsViewModel @Inject constructor( }.onSuccess { mutableState.update { it.copy( - message = "Applied your targets to Food goals.", + message = uiText(R.string.profile_targets_applied), targetApplyState = TargetApplyState.Success, targetApplyTargets = targets, ) @@ -943,7 +833,7 @@ class ProfileSettingsViewModel @Inject constructor( }.onFailure { error -> mutableState.update { it.copy( - message = error.message ?: "Could not apply targets to Food.", + message = error.messageOr(R.string.profile_error_apply_targets), targetApplyState = TargetApplyState.Failure, targetApplyTargets = targets, ) @@ -953,14 +843,152 @@ class ProfileSettingsViewModel @Inject constructor( } } +private class ProfileHealthSettingsActions( + private val healthRepository: HealthRepository, + private val foodRepository: FoodRepository, + private val mutableState: MutableStateFlow, + private val scope: CoroutineScope, +) { + fun refreshStatus() { + scope.launch { + runCatching { + val status = healthRepository.status() + val requestablePermissions = healthRepository.requestablePermissions() + val launchablePermissions = if (status.availability == HealthConnectAvailability.Available) { + requestablePermissions + } else { + emptySet() + } + mutableState.update { + it.copy( + availabilityLabel = status.availability.label(), + grantedPermissionCount = status.grantedPermissions.size, + requestablePermissionCount = requestablePermissions.size, + requestablePermissions = launchablePermissions, + canRequestPermissions = status.availability == HealthConnectAvailability.Available && + launchablePermissions.isNotEmpty(), + message = status.toMessage(requestablePermissions.size), + ) + } + loadStepSources() + }.onFailure { + mutableState.update { + it.copy( + availabilityLabel = uiText(R.string.profile_unknown), + grantedPermissionCount = 0, + requestablePermissionCount = 0, + requestablePermissions = emptySet(), + canRequestPermissions = false, + message = uiText(R.string.profile_error_refresh_health_status), + ) + } + } + } + } + + fun importToday() { + scope.launch { + runCatching { healthRepository.importDailySummary(LocalDate.now()) } + .onSuccess { summary -> mutableState.update { it.copy(message = summary.importMessage()) } } + .onFailure { error -> + mutableState.update { it.copy(message = error.messageOr(R.string.profile_error_import_health)) } + } + } + } + + fun syncRecentHealthData() { + scope.launch { + mutableState.update { it.copy(isHealthConnectSyncing = true) } + runCatching { healthRepository.refreshRecentData(LocalDate.now()) } + .onSuccess { result -> + mutableState.update { it.copy(isHealthConnectSyncing = false, message = result.toMessage()) } + } + .onFailure { error -> + mutableState.update { + it.copy( + isHealthConnectSyncing = false, + message = error.messageOr(R.string.profile_error_sync_health), + ) + } + } + } + } + + fun exportLatestWorkout() { + scope.launch { + runCatching { healthRepository.exportLatestWorkout() } + .onSuccess { recordId -> + val message = if (recordId != null) { + uiText(R.string.profile_exported_latest_workout) + } else { + uiText(R.string.profile_no_workout_exported) + } + mutableState.update { it.copy(message = message) } + } + .onFailure { error -> + mutableState.update { it.copy(message = error.messageOr(R.string.profile_error_export_workout)) } + } + } + } + + fun selectStepSource(packageName: String?) { + if (mutableState.value.isHealthConnectSyncing) return + mutableState.update { it.copy(isHealthConnectSyncing = true) } + scope.launch { + runCatching { + healthRepository.setPreferredStepsPackage(packageName) + healthRepository.refreshRecentData(LocalDate.now()) + }.onSuccess { + mutableState.update { + it.copy( + isHealthConnectSyncing = false, + message = uiText( + R.string.profile_steps_source_updated, + UiText.Argument.Nested(stepSourceLabel(packageName, it.stepSources)), + ), + ) + } + }.onFailure { error -> + mutableState.update { + it.copy( + isHealthConnectSyncing = false, + message = error.messageOr(R.string.profile_error_update_steps_source), + ) + } + } + } + } + + fun setIncludeBurnedCalories(enabled: Boolean) { + scope.launch { + runCatching { + val current = foodRepository.observeFoodGoal().first() + foodRepository.updateFoodGoal(current.copy(includeTrainingCalories = enabled)) + }.onFailure { error -> + mutableState.update { + it.copy(message = error.messageOr(R.string.profile_error_update_burned_calories)) + } + } + } + } + + private fun loadStepSources() { + scope.launch { + val sources = runCatching { healthRepository.readStepSources(LocalDate.now()) } + .getOrDefault(emptyList()) + mutableState.update { it.copy(stepSources = sources) } + } + } +} + private fun Result.rethrowCancellation(): Result = onFailure { error -> if (error is CancellationException) throw error } -private fun AccountAuthProvider.messageLabel(): String = when (this) { - AccountAuthProvider.Local -> "local account" - AccountAuthProvider.Google -> "Google" - AccountAuthProvider.GitHub -> "GitHub" +private fun AccountAuthProvider.messageLabel(): UiText = when (this) { + AccountAuthProvider.Local -> uiText(R.string.profile_local_account) + AccountAuthProvider.Google -> uiText(R.string.profile_google) + AccountAuthProvider.GitHub -> uiText(R.string.profile_github) } private fun AiCoachSettings.toUiState(): AiCoachSettingsUiState = AiCoachSettingsUiState( @@ -970,112 +998,172 @@ private fun AiCoachSettings.toUiState(): AiCoachSettingsUiState = AiCoachSetting localAgentKind = localAgentKind, hasApiKey = hasApiKey, providerLabel = providerKind.displayLabel(localAgentKind), - endpointLabel = baseUrl.ifBlank { "Not set" }, - modelLabel = modelName.ifBlank { "Not set" }, + endpointLabel = baseUrl.takeIf(String::isNotBlank)?.let(UiText::Verbatim) ?: uiText(R.string.profile_not_set), + modelLabel = modelName.takeIf(String::isNotBlank)?.let(UiText::Verbatim) ?: uiText(R.string.profile_not_set), localAgentLabel = localAgentKind.displayLabel(), - apiKeyLabel = if (hasApiKey) "Key saved" else "No API key", + apiKeyLabel = uiText(if (hasApiKey) R.string.profile_key_saved else R.string.profile_no_api_key), ) -private fun AiCoachProviderKind.displayLabel(localAgentKind: LocalAgentKind): String = when (this) { - AiCoachProviderKind.Disabled -> "Off" - AiCoachProviderKind.OpenAiCompatible -> "API-compatible endpoint" +private fun AiCoachProviderKind.displayLabel(localAgentKind: LocalAgentKind): UiText = when (this) { + AiCoachProviderKind.Disabled -> uiText(R.string.profile_off) + AiCoachProviderKind.OpenAiCompatible -> uiText(R.string.profile_api_compatible_endpoint) AiCoachProviderKind.LocalAgent -> localAgentKind.displayLabel() } -private fun LocalAgentKind.displayLabel(): String = when (this) { - LocalAgentKind.OpenClaw -> "OpenClaw local agent" - LocalAgentKind.HermesAgent -> "Hermes agent" - LocalAgentKind.Custom -> "Custom local agent" +private fun LocalAgentKind.displayLabel(): UiText = when (this) { + LocalAgentKind.OpenClaw -> uiText(R.string.profile_openclaw_agent) + LocalAgentKind.HermesAgent -> uiText(R.string.profile_hermes_agent) + LocalAgentKind.Custom -> uiText(R.string.profile_custom_local_agent) } -private fun HealthConnectImportResult.importMessage(): String = when (this) { +private fun HealthConnectImportResult.importMessage(): UiText = when (this) { is HealthConnectImportResult.Complete -> summary.importedSummaryMessage() - is HealthConnectImportResult.Partial -> - "Imported available Health Connect data, but ${failures.size} metric reads failed." + is HealthConnectImportResult.Partial -> uiText( + R.string.profile_health_import_partial, + UiText.Argument.Text(LocalizedFormatter.integer(failures.size.toLong())), + ) - is HealthConnectImportResult.Empty -> "No Health Connect data was found for today." + is HealthConnectImportResult.Empty -> uiText(R.string.profile_health_import_empty) - is HealthConnectImportResult.Cleared -> "Health Connect no longer has data for today; cached values were cleared." + is HealthConnectImportResult.Cleared -> uiText(R.string.profile_health_import_cleared) - is HealthConnectImportResult.Unavailable -> message + is HealthConnectImportResult.Unavailable -> UiText.Verbatim(message) - is HealthConnectImportResult.Failure -> message + is HealthConnectImportResult.Failure -> UiText.Verbatim(message) } -private fun com.musfit.domain.health.ImportedDailyHealthSummary.importedSummaryMessage(): String { - val stepsText = steps?.let { "$it steps" } ?: "health data" - val caloriesText = activeCaloriesKcal?.let { "${it.formatMetric()} kcal" } - val sleepText = sleepMinutes?.let { it.formatDuration() + " sleep" } - val exerciseText = exerciseMinutes?.let { it.formatDuration() + " exercise" } +private fun com.musfit.domain.health.ImportedDailyHealthSummary.importedSummaryMessage(): UiText { + val stepsText = steps?.let { + uiText( + R.string.profile_health_steps, + UiText.Argument.Text(LocalizedFormatter.integer(it)), + ) + } ?: uiText(R.string.profile_health_data) + val caloriesText = activeCaloriesKcal?.let { + uiText(R.string.profile_health_calories, UiText.Argument.Text(it.formatMetric())) + } + val sleepText = sleepMinutes?.let { + uiText(R.string.profile_health_sleep, UiText.Argument.Nested(it.formatDuration())) + } + val exerciseText = exerciseMinutes?.let { + uiText(R.string.profile_health_exercise, UiText.Argument.Nested(it.formatDuration())) + } val parts = listOfNotNull(stepsText, caloriesText, sleepText, exerciseText) - return "Imported ${parts.joinForSentence()} from Health Connect." + return uiText(R.string.profile_health_imported, UiText.Argument.Nested(parts.joinForSentence())) } -private fun HealthConnectRefreshResult.toMessage(): String { +private fun HealthConnectRefreshResult.toMessage(): UiText { if (importedDayCount == 0 && failedDayCount > 0) { - return "Health Connect sync failed for $failedDayCount ${if (failedDayCount == 1) "day" else "days"}." + return uiText( + R.string.profile_health_sync_failed, + UiText.Argument.Nested(failedDayCount.toDayCountText()), + ) } - val dayText = if (importedDayCount == 1) "1 day" else "$importedDayCount days" + val dayText = importedDayCount.toDayCountText() val metricText = when (bodyMetricCount) { 0 -> null - 1 -> "1 body metric" - else -> "$bodyMetricCount body metrics" + + else -> pluralUiText( + R.plurals.profile_health_body_metrics, + bodyMetricCount, + UiText.Argument.Text(LocalizedFormatter.integer(bodyMetricCount.toLong())), + ) } val warningText = when { - failedDayCount > 0 -> "$failedDayCount failed" - partialDayCount > 0 -> "$partialDayCount partial" + failedDayCount > 0 -> uiText( + R.string.profile_health_warning_failed, + UiText.Argument.Text(LocalizedFormatter.integer(failedDayCount.toLong())), + ) + + partialDayCount > 0 -> uiText( + R.string.profile_health_warning_partial, + UiText.Argument.Text(LocalizedFormatter.integer(partialDayCount.toLong())), + ) + else -> null } - return "Synced ${listOfNotNull(dayText, metricText, warningText).joinForSentence()} from Health Connect." + return uiText( + R.string.profile_health_synced, + UiText.Argument.Nested(listOfNotNull(dayText, metricText, warningText).joinForSentence()), + ) } -private fun Double.formatMetric(): String = if (this % 1.0 == 0.0) { - toInt().toString() -} else { - String.format(Locale.US, "%.1f", this) -} +private fun Int.toDayCountText(): UiText = pluralUiText( + R.plurals.profile_health_days, + this, + UiText.Argument.Text(LocalizedFormatter.integer(toLong())), +) -private fun Long.formatDuration(): String { +private fun Double.formatMetric(locale: Locale = Locale.getDefault()): String = LocalizedFormatter.number(this, maximumFractionDigits = 1, locale = locale) + +private fun Long.formatDuration(): UiText { val hours = this / 60L val minutes = this % 60L return if (hours > 0L) { - "${hours}h ${minutes.toString().padStart(2, '0')}m" + uiText( + R.string.profile_duration_hours_minutes, + UiText.Argument.Text(LocalizedFormatter.integer(hours)), + UiText.Argument.Text(LocalizedFormatter.integer(minutes)), + ) } else { - "$minutes min" + pluralUiText( + R.plurals.profile_duration_minutes, + minutes.toInt(), + UiText.Argument.Text(LocalizedFormatter.integer(minutes)), + ) } } -private fun List.joinForSentence(): String = when (size) { - 0 -> "health data" +private fun List.joinForSentence(): UiText = when (size) { + 0 -> uiText(R.string.profile_health_data) + 1 -> single() - 2 -> "${this[0]} and ${this[1]}" - else -> dropLast(1).joinToString(", ") + ", and " + last() + + 2 -> uiText( + R.string.profile_join_and, + UiText.Argument.Nested(this[0]), + UiText.Argument.Nested(this[1]), + ) + + else -> uiText( + R.string.profile_join_comma_and, + UiText.Argument.Nested( + dropLast(1).reduce { accumulated, next -> + uiText( + R.string.profile_join_comma, + UiText.Argument.Nested(accumulated), + UiText.Argument.Nested(next), + ) + }, + ), + UiText.Argument.Nested(last()), + ) } -private fun HealthConnectAvailability.label(): String = when (this) { - HealthConnectAvailability.Available -> "Available" - HealthConnectAvailability.NotInstalled -> "Install or update required" - HealthConnectAvailability.NotSupported -> "Not supported" +private fun HealthConnectAvailability.label(): UiText = when (this) { + HealthConnectAvailability.Available -> uiText(R.string.profile_health_available) + HealthConnectAvailability.NotInstalled -> uiText(R.string.profile_health_install_required) + HealthConnectAvailability.NotSupported -> uiText(R.string.profile_health_not_supported) } private fun com.musfit.domain.health.HealthConnectStatus.toMessage( requestablePermissionCount: Int, -): String = when (availability) { +): UiText = when (availability) { HealthConnectAvailability.NotInstalled -> - "Install or update Health Connect to sync health data with MusFit." + uiText(R.string.profile_health_install_message) HealthConnectAvailability.NotSupported -> - "Health Connect is not supported on this device." + uiText(R.string.profile_health_not_supported_message) HealthConnectAvailability.Available -> when { grantedPermissions.isEmpty() -> - "No Health Connect permissions are granted. Tap Enable Health Connect sync to choose what MusFit can access." + uiText(R.string.profile_health_no_permissions) grantedPermissions.size < requestablePermissionCount -> - "Some Health Connect permissions are granted. Tap Enable Health Connect sync to review or add access." + uiText(R.string.profile_health_some_permissions) else -> - "Health Connect is ready. MusFit can read and write the enabled data types." + uiText(R.string.profile_health_ready_message) } } diff --git a/feature/profile/src/main/java/com/musfit/ui/profile/ProfileUiText.kt b/feature/profile/src/main/java/com/musfit/ui/profile/ProfileUiText.kt new file mode 100644 index 00000000..e56d92bf --- /dev/null +++ b/feature/profile/src/main/java/com/musfit/ui/profile/ProfileUiText.kt @@ -0,0 +1,24 @@ +package com.musfit.ui.profile + +import com.musfit.feature.profile.R +import com.musfit.ui.text.UiText +import com.musfit.ui.text.uiText + +internal val MEASUREMENT_LABEL_RESOURCES = mapOf( + "waist" to R.string.profile_measurement_waist, + "chest" to R.string.profile_measurement_chest, + "arms" to R.string.profile_measurement_arms, + "thighs" to R.string.profile_measurement_thighs, + "hips" to R.string.profile_measurement_hips, + "body_fat" to R.string.profile_measurement_body_fat, +) + +internal fun List.joinedWithMiddleDot(): UiText = reduce { accumulated, next -> + uiText( + R.string.profile_join_middle_dot, + UiText.Argument.Nested(accumulated), + UiText.Argument.Nested(next), + ) +} + +internal fun Throwable.messageOr(resourceId: Int): UiText = message?.let(UiText::Verbatim) ?: uiText(resourceId) diff --git a/feature/profile/src/main/java/com/musfit/ui/profile/ProfileViewModel.kt b/feature/profile/src/main/java/com/musfit/ui/profile/ProfileViewModel.kt index 78954e3f..9b13e5cb 100644 --- a/feature/profile/src/main/java/com/musfit/ui/profile/ProfileViewModel.kt +++ b/feature/profile/src/main/java/com/musfit/ui/profile/ProfileViewModel.kt @@ -20,6 +20,10 @@ import com.musfit.domain.profile.BodyMetricsCalculator import com.musfit.domain.profile.GoalType import com.musfit.domain.profile.RecommendedTargets import com.musfit.domain.today.WeeklyGoalsCalculator +import com.musfit.feature.profile.R +import com.musfit.ui.text.LocalizedFormatter +import com.musfit.ui.text.UiText +import com.musfit.ui.text.uiText import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -45,7 +49,7 @@ data class WeightHeroState( data class MeasurementTile( val type: String, - val label: String, + val label: UiText, val value: Double?, val unit: String, val deltaFromPrevious: Double?, @@ -66,18 +70,9 @@ data class ProfileUiState( val weightEntries: List = emptyList(), val measurementEntries: Map> = emptyMap(), /** The "Goals & programs" row subline: pace · diet figure · program ×target. */ - val plansSummary: String = "", + val plansSummary: UiText? = null, val isHealthConnectNudgeVisible: Boolean = false, - val message: String? = null, -) - -private val MEASUREMENT_LABELS = mapOf( - "waist" to "Waist", - "chest" to "Chest", - "arms" to "Arms", - "thighs" to "Thighs", - "hips" to "Hips", - "body_fat" to "Body fat", + val message: UiText? = null, ) private fun defaultUnitFor(type: String) = if (type == "body_fat") "%" else "cm" @@ -91,7 +86,7 @@ class ProfileViewModel internal constructor( private val goalsRepository: GoalsRepository, private val dateProvider: () -> LocalDate, ) : ViewModel() { - private val messageFlow = MutableStateFlow(null) + private val messageFlow = MutableStateFlow(null) private val nudgeFlow = MutableStateFlow(false) /** The day every hub window is anchored to; re-resolved on each resume so a @@ -206,35 +201,35 @@ class ProfileViewModel internal constructor( fun saveProfile(profile: UserProfile) { viewModelScope.launch { runCatching { profileRepository.saveProfile(profile) } - .onFailure { messageFlow.value = it.message ?: "Could not save profile." } + .onFailure { messageFlow.value = it.messageOr(R.string.profile_error_save_profile) } } } fun logWeight(weightKg: Double) { viewModelScope.launch { runCatching { profileRepository.logWeight(weightKg) } - .onFailure { messageFlow.value = it.message ?: "Could not log weight." } + .onFailure { messageFlow.value = it.messageOr(R.string.profile_error_log_weight) } } } fun logMeasurement(type: String, value: Double, unit: String) { viewModelScope.launch { runCatching { profileRepository.logMeasurement(type, value, unit) } - .onFailure { messageFlow.value = it.message ?: "Could not log measurement." } + .onFailure { messageFlow.value = it.messageOr(R.string.profile_error_log_measurement) } } } fun editEntry(id: String, value: Double) { viewModelScope.launch { runCatching { profileRepository.updateEntryValue(id, value) } - .onFailure { messageFlow.value = it.message ?: "Could not update entry." } + .onFailure { messageFlow.value = it.messageOr(R.string.profile_error_update_entry) } } } fun deleteEntry(id: String) { viewModelScope.launch { runCatching { profileRepository.deleteEntry(id) } - .onFailure { messageFlow.value = it.message ?: "Could not delete entry." } + .onFailure { messageFlow.value = it.messageOr(R.string.profile_error_delete_entry) } } } @@ -295,7 +290,7 @@ private fun buildMeasurementTile( ?: history.lastOrNull() return MeasurementTile( type = type, - label = MEASUREMENT_LABELS[type] ?: type, + label = MEASUREMENT_LABEL_RESOURCES[type]?.let(::uiText) ?: UiText.Verbatim(type), value = latest?.value, unit = latest?.unit ?: defaultUnitFor(type), // Deltas subtract raw values across rows; safe while the log dialog fixes one unit per type. @@ -321,26 +316,49 @@ internal fun buildPlansSummary( goal: FoodGoal, routines: List, userGoals: UserGoals, -): String { + locale: Locale = Locale.getDefault(), +): UiText { val pace = profile?.let { when (it.goalType) { - GoalType.Maintain -> "Maintain" - GoalType.Lose -> "Lose ${it.goalPaceKgPerWeek.format1()} kg/wk" - GoalType.Gain -> "Gain ${it.goalPaceKgPerWeek.format1()} kg/wk" + GoalType.Maintain -> uiText(R.string.profile_goal_maintain) + + GoalType.Lose -> uiText( + R.string.profile_goal_lose_pace, + UiText.Argument.Text(LocalizedFormatter.number(it.goalPaceKgPerWeek, maximumFractionDigits = 1, locale = locale)), + ) + + GoalType.Gain -> uiText( + R.string.profile_goal_gain_pace, + UiText.Argument.Text(LocalizedFormatter.number(it.goalPaceKgPerWeek, maximumFractionDigits = 1, locale = locale)), + ) } } val dietFigure = when (goal.mode) { FoodGoalMode.HighProtein, FoodGoalMode.MuscleGain -> - "${goal.proteinGrams.roundToInt()} g protein" + uiText( + R.string.profile_value_protein_grams, + UiText.Argument.Text(LocalizedFormatter.integer(goal.proteinGrams.roundToInt().toLong(), locale = locale)), + ) - else -> String.format(Locale.US, "%,d kcal", goal.dailyCaloriesKcal.roundToInt()) + else -> uiText( + R.string.profile_value_kcal, + UiText.Argument.Text(LocalizedFormatter.integer(goal.dailyCaloriesKcal.roundToInt().toLong(), locale = locale)), + ) } val routine = routines.firstOrNull() // the coach's existing "next routine" convention val program = routine?.let { val name = it.programName ?: it.name - if (userGoals.weeklySessionTarget > 0) "$name ×${userGoals.weeklySessionTarget}" else name + if (userGoals.weeklySessionTarget > 0) { + uiText( + R.string.profile_program_weekly_target, + UiText.Argument.Text(name), + UiText.Argument.Text(LocalizedFormatter.integer(userGoals.weeklySessionTarget.toLong(), locale = locale)), + ) + } else { + UiText.Verbatim(name) + } } - return listOfNotNull(pace, dietFigure, program).joinToString(" · ") + return listOfNotNull(pace, dietFigure, program).joinedWithMiddleDot() } private const val DAY_MILLIS = 86_400_000L diff --git a/feature/profile/src/main/res/values/strings.xml b/feature/profile/src/main/res/values/strings.xml new file mode 100644 index 00000000..148eaa61 --- /dev/null +++ b/feature/profile/src/main/res/values/strings.xml @@ -0,0 +1,352 @@ + + + Waist + Chest + Arms + Thighs + Hips + Body fat + + Could not save profile. + Could not log weight. + Could not log measurement. + Could not update entry. + Could not delete entry. + + Maintain + Lose %1$s kg/wk + Gain %1$s kg/wk + %1$s g protein + %1$s kcal + %1$s ×%2$s + %1$s · %2$s + + Profile + Settings + Weight history + %1$s history + Measurements + + Log + Plans & progress + Open Health Connect settings + Connect Health Connect to mirror steps and heart rate + Set up + Open weight history + kg + No entries in the last 30 days. + Log again to see a trend. + No weight logged yet. + 30 days + 30 days · %1$s%% to goal + Log weight + %1$s%2$s kg · 7 days + + + + BMI %1$s + goal %1$s kg + goal %1$s kg · %2$s + maintain + lose %1$s/wk + gain %1$s/wk + Log %1$s + Open %1$s history + Tap to log + flat + Goals & programs + Set your goal, diet, and program + Open goals and programs in Food + Training progress + PRs, trends and volume per exercise + Nutrition trends + Weekly score · 7 and 28-day progress + + Sedentary + Light + Moderate + Active + Very active + Lose + Gain + Male + Female + + Refresh status to check whether Health Connect is ready. + All sources (unified) + Unknown + Not set + Off + Key saved + No API key + Google + GitHub + local account + Connect Google + Connect GitHub + Waiting for GitHub + Setup needed + Waiting + Ready + In progress + Missing Google OAuth client ID in this build. + Wait for GitHub to finish first. + Links Google to this local account. MusFit still keeps your data on this device. + Enter the code in GitHub to finish linking your local account. + Missing GitHub OAuth client ID in this build. + Uses GitHub device flow to link your local account. + + Could not prepare your local account. + Unable to refresh Health Connect status right now. Try again from the Profile tab. + Unable to import from Health Connect. + Unable to sync Health Connect data. + Exported latest workout to Health Connect. + No workout was exported. Check permissions and log a workout first. + Unable to export workout to Health Connect. + Updated steps source. MusFit now shows %1$s. + Unable to update steps source. + Could not update the burned-calorie setting. + Data erasure failed. + All local MusFit data was erased. A fresh local account is ready. + The account and its local MusFit data were erased. + Account name is required. + Could not save account. + Could not sign in with %1$s. + GitHub sign-in is not configured. + Starting GitHub sign-in. + Enter %1$s at GitHub to finish sign-in. + Could not sign in with GitHub. + Signed in with %1$s. + Could not sign in. + AI coach setup saved. + Could not save AI coach setup. + AI coach API key cleared. + Could not clear AI coach API key. + Testing AI coach connection… + AI coach connection is reachable. + AI coach connection is not reachable. + Applied your targets to Food goals. + Could not apply targets to Food. + + API-compatible endpoint + OpenClaw local agent + Hermes agent + Custom local agent + Imported available Health Connect data, but %1$s metric reads failed. + No Health Connect data was found for today. + Health Connect no longer has data for today; cached values were cleared. + health data + %1$s steps + %1$s kcal + %1$s sleep + %1$s exercise + Imported %1$s from Health Connect. + Health Connect sync failed for %1$s. + Synced %1$s from Health Connect. + %1$sh %2$sm + %1$s and %2$s + %1$s, %2$s + %1$s, and %2$s + Available + Install or update required + Not supported + Install or update Health Connect to sync health data with MusFit. + Health Connect is not supported on this device. + No Health Connect permissions are granted. Tap Enable Health Connect sync to choose what MusFit can access. + Some Health Connect permissions are granted. Tap Enable Health Connect sync to review or add access. + Health Connect is ready. MusFit can read and write the enabled data types. + %1$s failed + %1$s partial + + + %1$s day + %1$s days + + + %1$s body metric + %1$s body metrics + + + %1$s min + %1$s min + + + AI coach + The coach runs on your own model connection + Connection + Edit + Provider + Base URL + Model + Your key stays on this device. + Not set up + Testing + Test + Connected + Not reachable + Not tested yet + API key + •••• •••• saved + No key + Clear + AI coach setup + Bring your own agent or API-compatible endpoint + API + Agent + OpenClaw + Hermes + Custom + Model or agent id (optional) + API_SERVER_KEY + API key (optional) + Leave blank to keep the saved API_SERVER_KEY. + Paste API_SERVER_KEY from ~/.hermes/.env. Stored encrypted on this device. + Leave blank to keep the saved key. + Stored encrypted on this device. + Hermes requires the API_SERVER_KEY bearer token from ~/.hermes/.env. %1$s + Keys stay encrypted on this device. %1$s + Save + + Health Connect + Mirrors steps, weight and heart rate + Refresh + Syncing + Sync + Export + Steps source + The combined total can read higher than a single app because it merges every source. Pick one to mirror it exactly. + DEVICE SYNC + %1$s of %2$s + permissions granted + Refresh to check available health permissions. + All permissions granted + Review + Health Connect combined total + %1$s steps today + No step sources found for today yet. Sync or walk a little, then come back. + Needs app + + %1$s more to grant + %1$s more to grant + + + Grant %1$s more + Grant %1$s more + + + You + Local account + %1$s · data stays on this device + Stored on this device. Sync and sign-in are not enabled. + Name + Email (optional) + + Settings + Profile, identity, coach and device sync + Account + Profile details + Linked to this local account + Data & privacy + Local storage, retention and erasure + Connections + Preferences + Add burned calories to budget + kcal left = goal − eaten + burned + Units + Metric · kg, cm + Theme + System + Data transfer + Encrypted export and restore + Data + Local first + About + MusFit + Not configured in this build + Optional linking · no cloud sync + Linked + Waiting… + Link + %1$s cm + lose %1$s kg/wk + gain %1$s kg/wk + Add your age, height, and goal + Off · bring your own model + %1$s · %2$s + %1$s of %2$s permissions + Checking availability… + GitHub sign-in + Enter this code at GitHub: + Open GitHub + Close + + MusFit stores account and fitness data locally on this device. Encrypted export files saved outside the app remain under your control. + Encrypted backups + Export or restore a MusFit backup file + Delete this account + Erase this identity and only its local MusFit data + Delete all MusFit data + Erase every local account, then create a fresh local account + Health Connect deletion is optional and targets only records authored by MusFit. + Delete this account? + Delete all MusFit data? + This permanently removes the current account and all data it owns. Other local accounts are kept and one becomes active. + This permanently removes every local account and all owned Food, Training, profile, AI coach, and Health cache data. A blank local account will be created. + Also delete records MusFit authored in Health Connect + Encrypted export files already saved outside MusFit are not deleted. + Deleting… + Delete permanently + Cancel + Your profile + Sets your calorie and macro targets + Sex + Age + years + Height + cm + Activity level + Goal + Goal weight + Current weight + PACE + kg/week + Decrease pace + Increase pace + Apply to Food + Applying… + Applied + Retry + New targets: + · + Couldn\'t apply targets. Try again. + Keep + Weight (kg) + Log measurement + Value (%1$s) + 7d + 30d + 90d + All + 7 days + 30 days + 90 days + all time + Entries + No entries yet. Log one with the + button. + No entries in this range. + Delete entry? + %1$s %2$s · %3$s + Delete + Back + Log entry + goal %1$s %2$s + Log at least two entries in this range to see a trend. + Entry options + Edit value + %1$s – %2$s %3$s + Google sign-in is not configured. + No Google account is available for sign-in. + Google sign-in did not return a Google account. + Google sign-in returned an unreadable credential. + Google sign-in did not return an account id. + Google user + This build sends AI coach requests only to HTTPS endpoints. + diff --git a/feature/profile/src/production/java/com/musfit/ui/profile/AiCoachVariantCopy.kt b/feature/profile/src/production/java/com/musfit/ui/profile/AiCoachVariantCopy.kt index fccd9a97..849f2ce9 100644 --- a/feature/profile/src/production/java/com/musfit/ui/profile/AiCoachVariantCopy.kt +++ b/feature/profile/src/production/java/com/musfit/ui/profile/AiCoachVariantCopy.kt @@ -2,6 +2,3 @@ package com.musfit.ui.profile internal const val HERMES_DEFAULT_BASE_URL = "" internal const val AI_COACH_BASE_URL_PLACEHOLDER = "https://coach.example.com/v1/" -internal const val AI_COACH_LOCAL_AGENT_SUMMARY = - "Connects through a trusted HTTPS endpoint or HTTPS tunnel." -internal const val AI_COACH_ENDPOINT_POLICY_NOTE = "This build sends AI coach requests only to HTTPS endpoints." diff --git a/feature/profile/src/test/java/com/musfit/ui/profile/AccountUiTest.kt b/feature/profile/src/test/java/com/musfit/ui/profile/AccountUiTest.kt new file mode 100644 index 00000000..5e2c9a82 --- /dev/null +++ b/feature/profile/src/test/java/com/musfit/ui/profile/AccountUiTest.kt @@ -0,0 +1,11 @@ +package com.musfit.ui.profile + +import org.junit.Assert.assertEquals +import org.junit.Test + +class AccountUiTest { + @Test + fun accountInitials_ignoreBidiControlCharactersFromPseudoLocale() { + assertEquals("Y", "\u202eYou\u202c".accountInitials()) + } +} diff --git a/feature/profile/src/test/java/com/musfit/ui/profile/AiCoachVariantCopyTest.kt b/feature/profile/src/test/java/com/musfit/ui/profile/AiCoachVariantCopyTest.kt index 790bf485..58c56e9d 100644 --- a/feature/profile/src/test/java/com/musfit/ui/profile/AiCoachVariantCopyTest.kt +++ b/feature/profile/src/test/java/com/musfit/ui/profile/AiCoachVariantCopyTest.kt @@ -1,21 +1,29 @@ package com.musfit.ui.profile +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.musfit.feature.profile.R import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) class AiCoachVariantCopyTest { @Test fun endpointDefaultsAndHelpCopyMatchTheCurrentVariantPolicy() { + val context = ApplicationProvider.getApplicationContext() + val endpointPolicyNote = context.getString(R.string.profile_ai_endpoint_policy_note) if (HERMES_DEFAULT_BASE_URL.isBlank()) { assertTrue(AI_COACH_BASE_URL_PLACEHOLDER.startsWith("https://")) - assertTrue(AI_COACH_LOCAL_AGENT_SUMMARY.contains("HTTPS")) - assertTrue(AI_COACH_ENDPOINT_POLICY_NOTE.contains("HTTPS")) + assertTrue(endpointPolicyNote.contains("HTTPS")) } else { assertEquals("http://10.0.2.2:8080/v1/", HERMES_DEFAULT_BASE_URL) assertTrue(AI_COACH_BASE_URL_PLACEHOLDER.startsWith("http://10.0.2.2")) - assertTrue(AI_COACH_LOCAL_AGENT_SUMMARY.contains("private LAN")) - assertTrue(AI_COACH_ENDPOINT_POLICY_NOTE.contains("private IP")) + assertTrue(endpointPolicyNote.contains("private IP")) } } } diff --git a/feature/profile/src/test/java/com/musfit/ui/profile/ProfileSettingsViewModelTest.kt b/feature/profile/src/test/java/com/musfit/ui/profile/ProfileSettingsViewModelTest.kt index 63f109db..277e7d24 100644 --- a/feature/profile/src/test/java/com/musfit/ui/profile/ProfileSettingsViewModelTest.kt +++ b/feature/profile/src/test/java/com/musfit/ui/profile/ProfileSettingsViewModelTest.kt @@ -50,7 +50,10 @@ import com.musfit.domain.profile.ActivityLevel import com.musfit.domain.profile.GoalType import com.musfit.domain.profile.RecommendedTargets import com.musfit.domain.profile.Sex +import com.musfit.feature.profile.R import com.musfit.testing.MainDispatcherRule +import com.musfit.ui.text.UiText +import com.musfit.ui.text.uiText import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -77,6 +80,24 @@ class ProfileSettingsViewModelTest { val mainDispatcherRule = MainDispatcherRule() private val dispatcher get() = mainDispatcherRule.dispatcher + private fun assertResource(expectedResourceId: Int, actual: UiText?) { + assertTrue(actual is UiText.Resource) + assertEquals(expectedResourceId, (actual as UiText.Resource).resourceId) + } + + private fun UiText?.containsText(expected: String): Boolean = when (this) { + null -> false + is UiText.Verbatim -> value.contains(expected) + is UiText.Resource -> arguments.any { it.containsText(expected) } + is UiText.Plural -> arguments.any { it.containsText(expected) } + } + + private fun UiText.Argument.containsText(expected: String): Boolean = when (this) { + is UiText.Argument.Text -> value.contains(expected) + is UiText.Argument.Nested -> value.containsText(expected) + else -> false + } + private fun TestScope.settingsViewModel( healthRepository: HealthRepository = FakeHealthRepository(), accountRepository: AccountRepository = FakeAccountRepository(), @@ -174,7 +195,7 @@ class ProfileSettingsViewModelTest { viewModel.refreshStatus() dispatcher.scheduler.advanceUntilIdle() - assertEquals("Available", viewModel.state.value.availabilityLabel) + assertEquals(uiText(R.string.profile_health_available), viewModel.state.value.availabilityLabel) assertEquals(1, viewModel.state.value.grantedPermissionCount) assertEquals(setOf("steps"), viewModel.state.value.requestablePermissions) assertEquals(true, viewModel.state.value.canRequestPermissions) @@ -250,7 +271,7 @@ class ProfileSettingsViewModelTest { erasureRepository.requests, ) assertEquals(null, viewModel.state.value.accountErasureScope) - assertEquals("The account and its local MusFit data were erased.", viewModel.state.value.message) + assertEquals(uiText(R.string.profile_erased_account_data), viewModel.state.value.message) } @Test @@ -267,15 +288,12 @@ class ProfileSettingsViewModelTest { viewModel.refreshStatus() dispatcher.scheduler.advanceUntilIdle() - assertEquals("Install or update required", viewModel.state.value.availabilityLabel) + assertEquals(uiText(R.string.profile_health_install_required), viewModel.state.value.availabilityLabel) assertEquals(0, viewModel.state.value.grantedPermissionCount) assertTrue(viewModel.state.value.requestablePermissions.isEmpty()) assertEquals(1, viewModel.state.value.requestablePermissionCount) assertEquals(false, viewModel.state.value.canRequestPermissions) - assertEquals( - "Install or update Health Connect to sync health data with MusFit.", - viewModel.state.value.message, - ) + assertEquals(uiText(R.string.profile_health_install_message), viewModel.state.value.message) } @Test @@ -293,7 +311,7 @@ class ProfileSettingsViewModelTest { viewModel.refreshStatus() dispatcher.scheduler.advanceUntilIdle() - assertEquals("Not supported", viewModel.state.value.availabilityLabel) + assertEquals(uiText(R.string.profile_health_not_supported), viewModel.state.value.availabilityLabel) assertTrue(viewModel.state.value.requestablePermissions.isEmpty()) assertEquals(2, viewModel.state.value.requestablePermissionCount) assertEquals(false, viewModel.state.value.canRequestPermissions) @@ -314,10 +332,7 @@ class ProfileSettingsViewModelTest { viewModel.refreshStatus() dispatcher.scheduler.advanceUntilIdle() - assertEquals( - "No Health Connect permissions are granted. Tap Enable Health Connect sync to choose what MusFit can access.", - viewModel.state.value.message, - ) + assertEquals(uiText(R.string.profile_health_no_permissions), viewModel.state.value.message) assertEquals(setOf("steps", "weight"), viewModel.state.value.requestablePermissions) } @@ -330,7 +345,11 @@ class ProfileSettingsViewModelTest { dispatcher.scheduler.advanceUntilIdle() assertEquals(LocalDate.now(), repository.importedDate) - assertEquals("Imported 1200 steps, 100 kcal, 7h 30m sleep, and 35 min exercise from Health Connect.", viewModel.state.value.message) + assertResource(R.string.profile_health_imported, viewModel.state.value.message) + assertTrue(viewModel.state.value.message.containsText("1,200")) + assertTrue(viewModel.state.value.message.containsText("100")) + assertTrue(viewModel.state.value.message.containsText("30")) + assertTrue(viewModel.state.value.message.containsText("35")) } @Test @@ -341,7 +360,7 @@ class ProfileSettingsViewModelTest { viewModel.importToday() dispatcher.scheduler.advanceUntilIdle() - assertEquals("Health Connect permissions were revoked.", viewModel.state.value.message) + assertEquals(UiText.Verbatim("Health Connect permissions were revoked."), viewModel.state.value.message) } @Test @@ -354,7 +373,9 @@ class ProfileSettingsViewModelTest { assertEquals(LocalDate.now(), repository.refreshDate) assertFalse(viewModel.state.value.isHealthConnectSyncing) - assertEquals("Synced 7 days and 2 body metrics from Health Connect.", viewModel.state.value.message) + assertResource(R.string.profile_health_synced, viewModel.state.value.message) + assertTrue(viewModel.state.value.message.containsText("7")) + assertTrue(viewModel.state.value.message.containsText("2")) } @Test @@ -371,7 +392,8 @@ class ProfileSettingsViewModelTest { viewModel.syncRecentHealthData() dispatcher.scheduler.advanceUntilIdle() - assertEquals("Health Connect sync failed for 7 days.", viewModel.state.value.message) + assertResource(R.string.profile_health_sync_failed, viewModel.state.value.message) + assertTrue(viewModel.state.value.message.containsText("7")) } @Test @@ -383,7 +405,7 @@ class ProfileSettingsViewModelTest { dispatcher.scheduler.advanceUntilIdle() assertEquals(1, repository.exportCalls) - assertEquals("Exported latest workout to Health Connect.", viewModel.state.value.message) + assertEquals(uiText(R.string.profile_exported_latest_workout), viewModel.state.value.message) } @Test @@ -394,7 +416,7 @@ class ProfileSettingsViewModelTest { viewModel.exportLatestWorkout() dispatcher.scheduler.advanceUntilIdle() - assertEquals("No workout was exported. Check permissions and log a workout first.", viewModel.state.value.message) + assertEquals(uiText(R.string.profile_no_workout_exported), viewModel.state.value.message) } @Test @@ -405,7 +427,7 @@ class ProfileSettingsViewModelTest { viewModel.refreshStatus() dispatcher.scheduler.advanceUntilIdle() - assertEquals("Available", viewModel.state.value.availabilityLabel) + assertEquals(uiText(R.string.profile_health_available), viewModel.state.value.availabilityLabel) assertEquals(1, viewModel.state.value.grantedPermissionCount) assertEquals(setOf("steps"), viewModel.state.value.requestablePermissions) assertEquals(true, viewModel.state.value.canRequestPermissions) @@ -414,15 +436,12 @@ class ProfileSettingsViewModelTest { viewModel.refreshStatus() dispatcher.scheduler.advanceUntilIdle() - assertEquals("Unknown", viewModel.state.value.availabilityLabel) + assertEquals(uiText(R.string.profile_unknown), viewModel.state.value.availabilityLabel) assertEquals(0, viewModel.state.value.grantedPermissionCount) assertEquals(0, viewModel.state.value.requestablePermissionCount) assertTrue(viewModel.state.value.requestablePermissions.isEmpty()) assertEquals(false, viewModel.state.value.canRequestPermissions) - assertEquals( - "Unable to refresh Health Connect status right now. Try again from the Profile tab.", - viewModel.state.value.message, - ) + assertEquals(uiText(R.string.profile_error_refresh_health_status), viewModel.state.value.message) } @Test @@ -442,7 +461,7 @@ class ProfileSettingsViewModelTest { assertTrue(accountRepository.ensured) // init must guarantee an active account row exists assertEquals("Ava", viewModel.state.value.account.displayName) assertEquals("ava@example.com", viewModel.state.value.account.email) - assertEquals("Local account", viewModel.state.value.account.providerLabel) + assertEquals(uiText(R.string.profile_local_account_title), viewModel.state.value.account.providerLabel) } @Test @@ -453,7 +472,7 @@ class ProfileSettingsViewModelTest { val viewModel = settingsViewModel(accountRepository = accountRepository) dispatcher.scheduler.advanceUntilIdle() - assertEquals("account table corrupt", viewModel.state.value.message) + assertEquals(UiText.Verbatim("account table corrupt"), viewModel.state.value.message) } @Test @@ -466,7 +485,7 @@ class ProfileSettingsViewModelTest { viewModel.saveProfile(DEFAULT_USER_PROFILE) dispatcher.scheduler.advanceUntilIdle() - assertEquals("disk full", viewModel.state.value.message) + assertEquals(UiText.Verbatim("disk full"), viewModel.state.value.message) } @Test @@ -507,8 +526,14 @@ class ProfileSettingsViewModelTest { assertEquals(AccountAuthProvider.Google, accountRepository.linkedProfile?.provider) assertEquals("Ava", viewModel.state.value.account.displayName) assertEquals("ava@gmail.com", viewModel.state.value.account.email) - assertEquals("Google", viewModel.state.value.account.providerLabel) - assertEquals("Signed in with Google.", viewModel.state.value.message) + assertEquals(uiText(R.string.profile_google), viewModel.state.value.account.providerLabel) + assertEquals( + uiText( + R.string.profile_signed_in_provider, + UiText.Argument.Nested(uiText(R.string.profile_google)), + ), + viewModel.state.value.message, + ) } @Test @@ -529,7 +554,7 @@ class ProfileSettingsViewModelTest { ) dispatcher.scheduler.advanceUntilIdle() - assertEquals("Google sign-in failed", viewModel.state.value.message) + assertEquals(UiText.Verbatim("Google sign-in failed"), viewModel.state.value.message) } @Test @@ -556,7 +581,13 @@ class ProfileSettingsViewModelTest { assertEquals("WDJB-MJHT", viewModel.state.value.githubDeviceCode?.userCode) assertEquals("https://github.com/login/device", viewModel.state.value.githubDeviceCode?.verificationUri) assertEquals(AccountAuthProvider.GitHub, accountRepository.linkedProfile?.provider) - assertEquals("Signed in with GitHub.", viewModel.state.value.message) + assertEquals( + uiText( + R.string.profile_signed_in_provider, + UiText.Argument.Nested(uiText(R.string.profile_github)), + ), + viewModel.state.value.message, + ) } @Test @@ -575,7 +606,10 @@ class ProfileSettingsViewModelTest { dispatcher.scheduler.advanceUntilIdle() assertEquals(null, accountRepository.linkedProfile) - assertEquals("Enter WDJB-MJHT at GitHub to finish sign-in.", viewModel.state.value.message) + assertEquals( + uiText(R.string.profile_github_enter_code, UiText.Argument.Text("WDJB-MJHT")), + viewModel.state.value.message, + ) } @Test @@ -587,12 +621,12 @@ class ProfileSettingsViewModelTest { ) assertEquals(false, actions.google.enabled) - assertEquals("Connect Google", actions.google.buttonLabel) - assertEquals("Setup needed", actions.google.statusLabel) - assertEquals("Missing Google OAuth client ID in this build.", actions.google.supportingText) + assertEquals(uiText(R.string.profile_connect_google), actions.google.buttonLabel) + assertEquals(uiText(R.string.profile_setup_needed), actions.google.statusLabel) + assertEquals(uiText(R.string.profile_google_missing_client), actions.google.supportingText) assertEquals(false, actions.github.enabled) - assertEquals("Setup needed", actions.github.statusLabel) - assertEquals("Missing GitHub OAuth client ID in this build.", actions.github.supportingText) + assertEquals(uiText(R.string.profile_setup_needed), actions.github.statusLabel) + assertEquals(uiText(R.string.profile_github_missing_client), actions.github.supportingText) } @Test @@ -604,11 +638,11 @@ class ProfileSettingsViewModelTest { ) assertEquals(false, actions.google.enabled) - assertEquals("Wait for GitHub to finish first.", actions.google.supportingText) + assertEquals(uiText(R.string.profile_wait_github_finish), actions.google.supportingText) assertEquals(false, actions.github.enabled) - assertEquals("Waiting for GitHub", actions.github.buttonLabel) - assertEquals("In progress", actions.github.statusLabel) - assertEquals("Enter the code in GitHub to finish linking your local account.", actions.github.supportingText) + assertEquals(uiText(R.string.profile_waiting_github), actions.github.buttonLabel) + assertEquals(uiText(R.string.profile_in_progress), actions.github.statusLabel) + assertEquals(uiText(R.string.profile_github_enter_code_supporting), actions.github.supportingText) } @Test @@ -623,7 +657,7 @@ class ProfileSettingsViewModelTest { dispatcher.scheduler.advanceUntilIdle() assertEquals(true, viewModel.state.value.accountEditorOpen) - assertEquals("Account name is required.", viewModel.state.value.accountErrorMessage) + assertEquals(uiText(R.string.profile_account_name_required), viewModel.state.value.accountErrorMessage) assertEquals(null, accountRepository.updatedName) } @@ -677,10 +711,10 @@ class ProfileSettingsViewModelTest { dispatcher.scheduler.advanceUntilIdle() assertEquals(AiCoachProviderKind.OpenAiCompatible, viewModel.state.value.aiCoach.providerKind) - assertEquals("API-compatible endpoint", viewModel.state.value.aiCoach.providerLabel) - assertEquals("https://api.example.com/", viewModel.state.value.aiCoach.endpointLabel) - assertEquals("gpt-4.1-mini", viewModel.state.value.aiCoach.modelLabel) - assertEquals("Key saved", viewModel.state.value.aiCoach.apiKeyLabel) + assertEquals(uiText(R.string.profile_api_compatible_endpoint), viewModel.state.value.aiCoach.providerLabel) + assertEquals(UiText.Verbatim("https://api.example.com/"), viewModel.state.value.aiCoach.endpointLabel) + assertEquals(UiText.Verbatim("gpt-4.1-mini"), viewModel.state.value.aiCoach.modelLabel) + assertEquals(uiText(R.string.profile_key_saved), viewModel.state.value.aiCoach.apiKeyLabel) } @Test @@ -712,7 +746,7 @@ class ProfileSettingsViewModelTest { dispatcher.scheduler.advanceUntilIdle() assertEquals(false, viewModel.state.value.aiCoachEditorOpen) - assertEquals("AI coach setup saved.", viewModel.state.value.aiCoachMessage) + assertEquals(uiText(R.string.profile_ai_saved), viewModel.state.value.aiCoachMessage) assertEquals( AiCoachSettingsInput( providerKind = AiCoachProviderKind.OpenAiCompatible, @@ -761,7 +795,7 @@ class ProfileSettingsViewModelTest { dispatcher.scheduler.advanceUntilIdle() assertEquals(true, viewModel.state.value.aiCoachEditorOpen) - assertEquals("Enter a valid http(s) base URL.", viewModel.state.value.aiCoachErrorMessage) + assertEquals(UiText.Verbatim("Enter a valid http(s) base URL."), viewModel.state.value.aiCoachErrorMessage) } @Test @@ -788,7 +822,7 @@ class ProfileSettingsViewModelTest { dispatcher.scheduler.advanceUntilIdle() assertEquals(1, chatRepository.testCalls) - assertEquals("AI coach connection is reachable.", viewModel.state.value.aiCoachMessage) + assertEquals(uiText(R.string.profile_ai_reachable), viewModel.state.value.aiCoachMessage) assertFalse(viewModel.state.value.isAiCoachTesting) assertEquals(AiCoachTestState.Success, viewModel.state.value.aiCoachTestState) } @@ -803,7 +837,7 @@ class ProfileSettingsViewModelTest { viewModel.testAiCoachConnection() dispatcher.scheduler.advanceUntilIdle() - assertEquals("Connection refused", viewModel.state.value.aiCoachMessage) + assertEquals(UiText.Verbatim("Connection refused"), viewModel.state.value.aiCoachMessage) assertFalse(viewModel.state.value.isAiCoachTesting) assertEquals(AiCoachTestState.Failure, viewModel.state.value.aiCoachTestState) } @@ -845,7 +879,7 @@ class ProfileSettingsViewModelTest { assertEquals(77.0, saved.fatGrams, 0.001) assertEquals(true, saved.includeTrainingCalories) assertEquals(true, saved.useNetCarbs) - assertEquals("Applied your targets to Food goals.", viewModel.state.value.message) + assertEquals(uiText(R.string.profile_targets_applied), viewModel.state.value.message) assertEquals(TargetApplyState.Success, viewModel.state.value.targetApplyState) assertEquals( RecommendedTargets(2759.0, 144.0, 270.0, 77.0), @@ -863,7 +897,7 @@ class ProfileSettingsViewModelTest { viewModel.applyRecommendedTargetsToFood(RecommendedTargets(2759.0, 144.0, 270.0, 77.0)) dispatcher.scheduler.advanceUntilIdle() - assertEquals("disk full", viewModel.state.value.message) + assertEquals(UiText.Verbatim("disk full"), viewModel.state.value.message) assertEquals(TargetApplyState.Failure, viewModel.state.value.targetApplyState) assertEquals( RecommendedTargets(2759.0, 144.0, 270.0, 77.0), @@ -932,10 +966,7 @@ class ProfileSettingsViewModelTest { viewModel.reportAiCoachLocalNetworkPermissionDenied(deniedMessage) dispatcher.scheduler.advanceUntilIdle() - assertEquals( - deniedMessage, - viewModel.state.value.aiCoachMessage, - ) + assertEquals(UiText.Verbatim(deniedMessage), viewModel.state.value.aiCoachMessage) } @Test @@ -976,7 +1007,7 @@ class ProfileSettingsViewModelTest { viewModel.setIncludeBurnedCalories(true) dispatcher.scheduler.advanceUntilIdle() - assertEquals("disk full", viewModel.state.value.message) + assertEquals(UiText.Verbatim("disk full"), viewModel.state.value.message) } @Test @@ -997,7 +1028,7 @@ class ProfileSettingsViewModelTest { dispatcher.scheduler.advanceUntilIdle() assertEquals(1, aiCoachRepository.clearCalls) - assertEquals("AI coach API key cleared.", viewModel.state.value.aiCoachMessage) + assertEquals(uiText(R.string.profile_ai_key_cleared), viewModel.state.value.aiCoachMessage) } private fun TestScope.observeSettings(viewModel: ProfileSettingsViewModel): Job = backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { diff --git a/feature/profile/src/test/java/com/musfit/ui/profile/ProfileViewModelTest.kt b/feature/profile/src/test/java/com/musfit/ui/profile/ProfileViewModelTest.kt index 0f8edbd0..4c690e37 100644 --- a/feature/profile/src/test/java/com/musfit/ui/profile/ProfileViewModelTest.kt +++ b/feature/profile/src/test/java/com/musfit/ui/profile/ProfileViewModelTest.kt @@ -44,6 +44,7 @@ import com.musfit.domain.profile.GoalType import com.musfit.domain.profile.RecommendedTargets import com.musfit.domain.profile.Sex import com.musfit.testing.MainDispatcherRule +import com.musfit.ui.text.UiText import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow @@ -61,6 +62,7 @@ import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import java.time.LocalDate +import java.util.Locale @OptIn(ExperimentalCoroutinesApi::class) class ProfileViewModelTest { @@ -125,7 +127,7 @@ class ProfileViewModelTest { lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_START) testScheduler.runCurrent() - assertTrue(viewModel.state.value.plansSummary.contains("Initial program")) + assertTrue(viewModel.state.value.plansSummary.containsText("Initial program")) assertEquals(1, trainingRepository.activeRoutineSummaryCollectors) assertEquals(1, trainingRepository.routineSummarySubscriptionStarts) @@ -139,11 +141,11 @@ class ProfileViewModelTest { trainingRepository.routines.value = listOf(routineSummary("Updated while stopped")) testScheduler.runCurrent() - assertTrue(viewModel.state.value.plansSummary.contains("Initial program")) + assertTrue(viewModel.state.value.plansSummary.containsText("Initial program")) lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_START) testScheduler.runCurrent() - assertTrue(viewModel.state.value.plansSummary.contains("Updated while stopped")) + assertTrue(viewModel.state.value.plansSummary.containsText("Updated while stopped")) assertEquals(1, trainingRepository.activeRoutineSummaryCollectors) assertEquals(2, trainingRepository.routineSummarySubscriptionStarts) @@ -410,7 +412,7 @@ class ProfileViewModelTest { viewModel.saveProfile(DEFAULT_USER_PROFILE) dispatcher.scheduler.advanceUntilIdle() - assertEquals("disk full", viewModel.state.value.message) + assertEquals(UiText.Verbatim("disk full"), viewModel.state.value.message) } @Test @@ -477,7 +479,10 @@ class ProfileViewModelTest { dispatcher.scheduler.advanceUntilIdle() // FakeFoodGoalRepo default: Balanced, 2000 kcal → the calorie figure with grouping. - assertEquals("Gain 0.3 kg/wk · 2,000 kcal · Beginner Program ×4", viewModel.state.value.plansSummary) + assertTrue(viewModel.state.value.plansSummary.containsText("0.3")) + assertTrue(viewModel.state.value.plansSummary.containsText("2,000")) + assertTrue(viewModel.state.value.plansSummary.containsText("Beginner Program")) + assertTrue(viewModel.state.value.plansSummary.containsText("4")) } @Test @@ -492,7 +497,7 @@ class ProfileViewModelTest { dispatcher.scheduler.advanceUntilIdle() // DEFAULT_USER_PROFILE is Maintain; no routine → no program part. - assertEquals("Maintain · 180 g protein", viewModel.state.value.plansSummary) + assertTrue(viewModel.state.value.plansSummary.containsText("180")) } @Test @@ -502,14 +507,15 @@ class ProfileViewModelTest { val viewModel = profileViewModel(trainingRepository = training, goalsRepository = goals) dispatcher.scheduler.advanceUntilIdle() - assertEquals("Maintain · 2,000 kcal", viewModel.state.value.plansSummary) + assertTrue(viewModel.state.value.plansSummary.containsText("2,000")) training.routines.value = listOf( RoutineSummary(id = "r1", name = "Machine A", notes = null, exerciseCount = 5, targetSetCount = 15, isStarter = false), ) dispatcher.scheduler.advanceUntilIdle() // No programName → routine name; target 0 drops the ×n multiplier. - assertEquals("Maintain · 2,000 kcal · Machine A", viewModel.state.value.plansSummary) + assertTrue(viewModel.state.value.plansSummary.containsText("2,000")) + assertTrue(viewModel.state.value.plansSummary.containsText("Machine A")) } @Test @@ -527,13 +533,43 @@ class ProfileViewModelTest { val summary = viewModel.state.value.plansSummary if (mode in proteinLed) { - assertTrue("$mode figure", summary.contains("160 g protein")) + assertTrue("$mode figure", summary.containsText("160")) } else { - assertTrue("$mode figure", summary.contains("2,100 kcal")) + assertTrue("$mode figure", summary.containsText("2,100")) } } } + @Test + fun plansSummary_usesRequestedLocaleForGroupedNumbers() { + val summary = buildPlansSummary( + profile = DEFAULT_USER_PROFILE, + goal = FoodGoal( + 2100.0, 160.0, 220.0, 70.0, 30.0, 50.0, 20.0, 2300.0, + FoodGoalMode.Balanced, + includeTrainingCalories = false, + ), + routines = emptyList(), + userGoals = UserGoals(weeklySessionTarget = 0), + locale = Locale.GERMANY, + ) + + assertTrue(summary.containsText("2.100")) + } + + private fun UiText?.containsText(expected: String): Boolean = when (this) { + null -> false + is UiText.Verbatim -> value.contains(expected) + is UiText.Resource -> arguments.any { it.containsText(expected) } + is UiText.Plural -> arguments.any { it.containsText(expected) } + } + + private fun UiText.Argument.containsText(expected: String): Boolean = when (this) { + is UiText.Argument.Text -> value.contains(expected) + is UiText.Argument.Nested -> value.containsText(expected) + else -> false + } + @Test fun hcNudge_visibleWhenNoPermissionsGranted() = runTest { val health = FakeHealthRepo() // status(): Available + granted setOf("steps") → hidden diff --git a/feature/profile/src/testInternalDebug/java/com/musfit/ui/profile/ProfileComposeSemanticsTest.kt b/feature/profile/src/testInternalDebug/java/com/musfit/ui/profile/ProfileComposeSemanticsTest.kt index 48f81871..f189522b 100644 --- a/feature/profile/src/testInternalDebug/java/com/musfit/ui/profile/ProfileComposeSemanticsTest.kt +++ b/feature/profile/src/testInternalDebug/java/com/musfit/ui/profile/ProfileComposeSemanticsTest.kt @@ -28,6 +28,7 @@ import com.musfit.data.repository.LocalAgentKind import com.musfit.domain.health.StepSource import com.musfit.domain.profile.GoalType import com.musfit.domain.profile.Sex +import com.musfit.ui.text.UiText import com.musfit.ui.theme.MusFitTheme import com.musfit.ui.theme.TabAccentRole import com.musfit.ui.theme.tabAccentFor @@ -142,7 +143,7 @@ class ProfileComposeSemanticsTest { MusFitTheme { HealthConnectSettingsPage( state = ProfileSettingsUiState( - availabilityLabel = "Available", + availabilityLabel = UiText.Verbatim("Available"), isHealthConnectSyncing = true, stepSources = listOf(StepSource("phone", "Phone", 1_234)), ), diff --git a/feature/profile/src/testInternalDebug/java/com/musfit/ui/profile/ProfileScreenshotRegressionTest.kt b/feature/profile/src/testInternalDebug/java/com/musfit/ui/profile/ProfileScreenshotRegressionTest.kt index 33622203..ff638e4b 100644 --- a/feature/profile/src/testInternalDebug/java/com/musfit/ui/profile/ProfileScreenshotRegressionTest.kt +++ b/feature/profile/src/testInternalDebug/java/com/musfit/ui/profile/ProfileScreenshotRegressionTest.kt @@ -13,6 +13,8 @@ import androidx.compose.ui.test.onRoot import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.LayoutDirection import com.github.takahirom.roborazzi.captureRoboImage +import com.musfit.data.repository.DEFAULT_USER_PROFILE +import com.musfit.domain.profile.GoalType import com.musfit.ui.theme.MusFitTheme import com.musfit.ui.theme.TabAccentRole import com.musfit.ui.theme.tabAccentFor @@ -32,36 +34,88 @@ class ProfileScreenshotRegressionTest { @Test fun profile_foldable_light_ltr() { + capture("profile-foldable-light-ltr.png") { + SettingsFixture() + } + } + + @Test + @Config(qualifiers = "en-rXA-w400dp-h800dp-mdpi") + fun profileEditor_phone_pseudo_largeFont() { + capture("profile-editor-phone-pseudo-font-150.png", fontScale = 1.5f) { + ProfileEditSheet( + initial = DEFAULT_USER_PROFILE, + initialWeightKg = 80.0, + onDismiss = {}, + onSave = { _, _ -> }, + ) + } + } + + @Test + @Config(qualifiers = "ar-rXB-w400dp-h800dp-mdpi") + fun profileSettings_phone_pseudoRtl() { + capture("profile-settings-phone-pseudo-rtl.png", rtl = true) { + SettingsFixture() + } + } + + @Test + @Config(qualifiers = "de-rDE-w610dp-h900dp-mdpi") + fun profileEditor_foldable_german() { + capture("profile-editor-foldable-german.png") { + ProfileEditSheet( + initial = DEFAULT_USER_PROFILE.copy(goalType = GoalType.Gain, goalPaceKgPerWeek = 0.3), + initialWeightKg = 80.5, + onDismiss = {}, + onSave = { _, _ -> }, + ) + } + } + + private fun capture( + fileName: String, + rtl: Boolean = false, + fontScale: Float = 1f, + content: @Composable () -> Unit, + ) { compose.setContent { - ScreenshotFrame { - SettingsHub( - state = ProfileSettingsUiState(), - accent = tabAccentFor(TabAccentRole.Profile), - onBack = {}, - onEditAccount = {}, - onOpenProfileDetails = {}, - onGoogleSignIn = {}, - onGitHubSignIn = {}, - onOpenAiCoach = {}, - onOpenHealthConnect = {}, - onOpenDataTransfer = {}, - onIncludeBurnedCaloriesChange = {}, - googleSignInConfigured = false, - versionName = "test", - ) - } + ScreenshotFrame(rtl = rtl, fontScale = fontScale, content = content) } compose.waitForIdle() - compose.onRoot().captureRoboImage("profile-foldable-light-ltr.png") + compose.onRoot().captureRoboImage(fileName) } } @Composable -private fun ScreenshotFrame(content: @Composable () -> Unit) { +private fun SettingsFixture() { + SettingsHub( + state = ProfileSettingsUiState(), + accent = tabAccentFor(TabAccentRole.Profile), + onBack = {}, + onEditAccount = {}, + onOpenProfileDetails = {}, + onGoogleSignIn = {}, + onGitHubSignIn = {}, + onOpenAiCoach = {}, + onOpenHealthConnect = {}, + onOpenDataTransfer = {}, + onIncludeBurnedCaloriesChange = {}, + googleSignInConfigured = false, + versionName = "test", + ) +} + +@Composable +private fun ScreenshotFrame( + rtl: Boolean = false, + fontScale: Float = 1f, + content: @Composable () -> Unit, +) { val density = LocalDensity.current CompositionLocalProvider( - LocalDensity provides Density(density.density, 1f), - LocalLayoutDirection provides LayoutDirection.Ltr, + LocalDensity provides Density(density.density, fontScale), + LocalLayoutDirection provides if (rtl) LayoutDirection.Rtl else LayoutDirection.Ltr, ) { MusFitTheme(darkTheme = false) { Box(Modifier.fillMaxSize().background(MusFitTheme.colors.background)) { content() } diff --git a/feature/profile/src/testInternalDebug/screenshots/profile-editor-foldable-german.png b/feature/profile/src/testInternalDebug/screenshots/profile-editor-foldable-german.png new file mode 100644 index 0000000000000000000000000000000000000000..b1b09dd73f56df19f14f869ebdca135dcb2a1f70 GIT binary patch literal 41727 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 literal 0 HcmV?d00001 diff --git a/feature/profile/src/testInternalDebug/screenshots/profile-settings-phone-pseudo-rtl.png b/feature/profile/src/testInternalDebug/screenshots/profile-settings-phone-pseudo-rtl.png new file mode 100644 index 0000000000000000000000000000000000000000..2e497f88531923b280eb1c72133809a9fb3d3bb0 GIT binary patch literal 44998 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 Kl|m(pp#K9hKr-L} literal 0 HcmV?d00001 From 85a547acc686edad781ee31e78737be4cd79131b Mon Sep 17 00:00:00 2001 From: Moustafa Attia <72319345+attmous@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:05:16 +0200 Subject: [PATCH 2/4] refactor(today): localize app presentation copy --- .../ui/permissions/LocalNetworkPermission.kt | 10 +- app/src/internal/res/values/strings.xml | 1 + .../ui/permissions/LocalNetworkPermission.kt | 1 - .../HealthPermissionsRationaleActivity.kt | 74 ++++- .../main/java/com/musfit/ui/AppDestination.kt | 12 +- .../main/java/com/musfit/ui/AppNavGraph.kt | 33 ++- .../main/java/com/musfit/ui/CoachChatEntry.kt | 5 +- .../com/musfit/ui/ProfileSettingsEntry.kt | 5 +- .../ui/transfer/DataTransferActivity.kt | 61 ++-- .../ui/transfer/DataTransferViewModel.kt | 31 +- app/src/main/res/values/strings.xml | 75 +++++ .../ui/permissions/LocalNetworkPermission.kt | 1 - .../HealthPermissionContractTest.kt | 23 +- .../LocalNetworkVariantContractTest.kt | 7 +- .../java/com/musfit/ui/AppDestinationTest.kt | 10 +- .../com/musfit/ui/AppNavigationChromeTest.kt | 8 + .../ui/transfer/DataTransferViewModelTest.kt | 18 +- .../ui/MusFitScreenshotRegressionTest.kt | 20 ++ .../navigation-foldable-pseudo-rtl.png | Bin 0 -> 12781 bytes .../navigation-phone-pseudo-font-150.png | Bin 0 -> 16309 bytes .../com/musfit/ui/text/LocalizedFormatter.kt | 2 + .../musfit/ui/text/LocalizedFormatterTest.kt | 13 + .../com/musfit/domain/today/TodayMetrics.kt | 211 ++------------ .../musfit/domain/today/TodayMetricsTest.kt | 174 ------------ feature/today/build.gradle.kts | 5 +- .../com/musfit/ui/today/CoachChatViewModel.kt | 31 +- .../java/com/musfit/ui/today/CoachFeedUi.kt | 152 +++++----- .../today/TodayActivityMetricPresentation.kt | 69 +++++ .../ui/today/TodayBodyMetricPresentation.kt | 60 ++++ .../musfit/ui/today/TodayMetricFormatting.kt | 44 +++ .../ui/today/TodayMetricPresentation.kt | 54 ++++ .../today/TodayNutritionMetricPresentation.kt | 73 +++++ .../java/com/musfit/ui/today/TodayScreen.kt | 36 ++- .../com/musfit/ui/today/TodayViewModel.kt | 46 +-- .../java/com/musfit/ui/today/TodayVitalsUi.kt | 267 ++++++++++-------- feature/today/src/main/res/values/strings.xml | 106 +++++++ .../ui/today/TodayMetricPresentationTest.kt | 177 ++++++++++++ .../com/musfit/ui/today/TodayViewModelTest.kt | 40 ++- .../ui/today/TodayComposeSemanticsTest.kt | 22 +- .../ui/today/TodayScreenshotRegressionTest.kt | 131 ++++++++- .../today-dashboard-phone-pseudo-font-150.png | Bin 0 -> 25065 bytes .../today-dashboard-phone-pseudo-rtl.png | Bin 0 -> 18592 bytes .../today-vitals-foldable-german.png | Bin 0 -> 21839 bytes .../HealthPermissionInventory.kt | 65 +++-- 44 files changed, 1437 insertions(+), 736 deletions(-) create mode 100644 app/src/testInternalDebug/screenshots/navigation-foldable-pseudo-rtl.png create mode 100644 app/src/testInternalDebug/screenshots/navigation-phone-pseudo-font-150.png create mode 100644 feature/today/src/main/java/com/musfit/ui/today/TodayActivityMetricPresentation.kt create mode 100644 feature/today/src/main/java/com/musfit/ui/today/TodayBodyMetricPresentation.kt create mode 100644 feature/today/src/main/java/com/musfit/ui/today/TodayMetricFormatting.kt create mode 100644 feature/today/src/main/java/com/musfit/ui/today/TodayMetricPresentation.kt create mode 100644 feature/today/src/main/java/com/musfit/ui/today/TodayNutritionMetricPresentation.kt create mode 100644 feature/today/src/main/res/values/strings.xml create mode 100644 feature/today/src/test/java/com/musfit/ui/today/TodayMetricPresentationTest.kt create mode 100644 feature/today/src/testInternalDebug/screenshots/today-dashboard-phone-pseudo-font-150.png create mode 100644 feature/today/src/testInternalDebug/screenshots/today-dashboard-phone-pseudo-rtl.png create mode 100644 feature/today/src/testInternalDebug/screenshots/today-vitals-foldable-german.png diff --git a/app/src/internal/java/com/musfit/ui/permissions/LocalNetworkPermission.kt b/app/src/internal/java/com/musfit/ui/permissions/LocalNetworkPermission.kt index a587266b..67a93fa8 100644 --- a/app/src/internal/java/com/musfit/ui/permissions/LocalNetworkPermission.kt +++ b/app/src/internal/java/com/musfit/ui/permissions/LocalNetworkPermission.kt @@ -7,12 +7,8 @@ import androidx.core.content.ContextCompat import com.musfit.data.remote.coach.AiCoachEndpointPolicy const val LOCAL_NETWORK_PERMISSION = "android.permission.ACCESS_LOCAL_NETWORK" -const val LOCAL_NETWORK_PERMISSION_DENIED_MESSAGE = - "Allow Local Network access to reach the configured private AI coach endpoint." -fun requiresLocalNetworkPermission(baseUrl: String): Boolean = - Build.VERSION.SDK_INT >= 37 && AiCoachEndpointPolicy.requiresPrivateLanRouting(baseUrl) +fun requiresLocalNetworkPermission(baseUrl: String): Boolean = Build.VERSION.SDK_INT >= 37 && AiCoachEndpointPolicy.requiresPrivateLanRouting(baseUrl) -fun hasLocalNetworkPermission(context: Context): Boolean = - Build.VERSION.SDK_INT < 37 || - ContextCompat.checkSelfPermission(context, LOCAL_NETWORK_PERMISSION) == PackageManager.PERMISSION_GRANTED +fun hasLocalNetworkPermission(context: Context): Boolean = Build.VERSION.SDK_INT < 37 || + ContextCompat.checkSelfPermission(context, LOCAL_NETWORK_PERMISSION) == PackageManager.PERMISSION_GRANTED diff --git a/app/src/internal/res/values/strings.xml b/app/src/internal/res/values/strings.xml index effd5510..bab2b705 100644 --- a/app/src/internal/res/values/strings.xml +++ b/app/src/internal/res/values/strings.xml @@ -1,3 +1,4 @@ MusFit Internal + Allow Local Network access to reach the configured private AI coach endpoint. diff --git a/app/src/legacyMigration/java/com/musfit/ui/permissions/LocalNetworkPermission.kt b/app/src/legacyMigration/java/com/musfit/ui/permissions/LocalNetworkPermission.kt index 73abd9f6..2d620786 100644 --- a/app/src/legacyMigration/java/com/musfit/ui/permissions/LocalNetworkPermission.kt +++ b/app/src/legacyMigration/java/com/musfit/ui/permissions/LocalNetworkPermission.kt @@ -5,7 +5,6 @@ package com.musfit.ui.permissions import android.content.Context const val LOCAL_NETWORK_PERMISSION = "" -const val LOCAL_NETWORK_PERMISSION_DENIED_MESSAGE = "Use an HTTPS AI coach endpoint." fun requiresLocalNetworkPermission(baseUrl: String): Boolean = false diff --git a/app/src/main/java/com/musfit/integrations/healthconnect/HealthPermissionsRationaleActivity.kt b/app/src/main/java/com/musfit/integrations/healthconnect/HealthPermissionsRationaleActivity.kt index 648f956d..48549500 100644 --- a/app/src/main/java/com/musfit/integrations/healthconnect/HealthPermissionsRationaleActivity.kt +++ b/app/src/main/java/com/musfit/integrations/healthconnect/HealthPermissionsRationaleActivity.kt @@ -1,8 +1,10 @@ package com.musfit.integrations.healthconnect +import android.content.res.Resources import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -14,7 +16,10 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.musfit.R import com.musfit.configureMusFitEdgeToEdge import com.musfit.ui.theme.MusFitTheme @@ -44,22 +49,21 @@ internal fun HealthPermissionsRationaleScreen( verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text( - text = "MusFit Health Connect access", + text = stringResource(R.string.health_rationale_title), style = MusFitTheme.typography.headlineSmall, color = MusFitTheme.colors.onSurface, ) Text( - text = "You choose each data type in Health Connect. MusFit uses only the access " + - "you grant, and you can change it at any time.", + text = stringResource(R.string.health_rationale_intro), style = MusFitTheme.typography.bodyMedium, color = MusFitTheme.colors.onSurfaceVariant, ) HealthPermissionRationaleSection( - title = "MusFit can read", + title = stringResource(R.string.health_rationale_read), items = items.filter { it.access == HealthPermissionAccess.Read }, ) HealthPermissionRationaleSection( - title = "MusFit can write", + title = stringResource(R.string.health_rationale_write), items = items.filter { it.access == HealthPermissionAccess.Write }, ) } @@ -70,16 +74,74 @@ private fun HealthPermissionRationaleSection( title: String, items: List, ) { + val resources = LocalResources.current Text( text = title, style = MusFitTheme.typography.titleMedium, color = MusFitTheme.colors.onSurface, ) items.forEach { item -> + val rationale = item.resolveRationale(resources) Text( - text = "\u2022 ${item.label}: ${item.purpose}", + text = stringResource(R.string.health_rationale_item, rationale.label, rationale.purpose), style = MusFitTheme.typography.bodyMedium, color = MusFitTheme.colors.onSurfaceVariant, ) } } + +internal data class HealthPermissionRationaleText( + val label: String, + val purpose: String, +) + +internal fun HealthPermissionRationaleItem.resolveRationale(resources: Resources): HealthPermissionRationaleText { + val resourceIds = rationale.resourceIds() + return HealthPermissionRationaleText( + label = resources.getString(resourceIds.label), + purpose = resources.getString(resourceIds.purpose), + ) +} + +private data class HealthPermissionRationaleResourceIds( + @param:StringRes val label: Int, + @param:StringRes val purpose: Int, +) + +private fun HealthPermissionRationale.resourceIds(): HealthPermissionRationaleResourceIds = when (this) { + HealthPermissionRationale.Steps -> rationaleResources(R.string.health_steps_label, R.string.health_steps_purpose) + + HealthPermissionRationale.ActiveCalories -> + rationaleResources(R.string.health_active_calories_label, R.string.health_active_calories_purpose) + + HealthPermissionRationale.TotalCalories -> + rationaleResources(R.string.health_total_calories_label, R.string.health_total_calories_purpose) + + HealthPermissionRationale.Distance -> rationaleResources(R.string.health_distance_label, R.string.health_distance_purpose) + + HealthPermissionRationale.Sleep -> rationaleResources(R.string.health_sleep_label, R.string.health_sleep_purpose) + + HealthPermissionRationale.ExerciseSessions -> + rationaleResources(R.string.health_exercise_sessions_label, R.string.health_exercise_sessions_purpose) + + HealthPermissionRationale.Weight -> rationaleResources(R.string.health_weight_label, R.string.health_weight_purpose) + + HealthPermissionRationale.BodyFat -> rationaleResources(R.string.health_body_fat_label, R.string.health_body_fat_purpose) + + HealthPermissionRationale.RestingHeartRate -> + rationaleResources(R.string.health_resting_heart_rate_label, R.string.health_resting_heart_rate_purpose) + + HealthPermissionRationale.HeartRateVariability -> rationaleResources(R.string.health_hrv_label, R.string.health_hrv_purpose) + + HealthPermissionRationale.Workouts -> rationaleResources(R.string.health_workouts_label, R.string.health_workouts_purpose) + + HealthPermissionRationale.MealsAndNutrition -> + rationaleResources(R.string.health_meals_nutrition_label, R.string.health_meals_nutrition_purpose) + + HealthPermissionRationale.Water -> rationaleResources(R.string.health_water_label, R.string.health_water_purpose) +} + +private fun rationaleResources( + @StringRes label: Int, + @StringRes purpose: Int, +) = HealthPermissionRationaleResourceIds(label = label, purpose = purpose) diff --git a/app/src/main/java/com/musfit/ui/AppDestination.kt b/app/src/main/java/com/musfit/ui/AppDestination.kt index fd1b73fe..e8527f5a 100644 --- a/app/src/main/java/com/musfit/ui/AppDestination.kt +++ b/app/src/main/java/com/musfit/ui/AppDestination.kt @@ -1,5 +1,6 @@ package com.musfit.ui +import androidx.annotation.StringRes import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.FitnessCenter import androidx.compose.material.icons.filled.Person @@ -10,20 +11,21 @@ import androidx.compose.material.icons.outlined.Person import androidx.compose.material.icons.outlined.Restaurant import androidx.compose.material.icons.outlined.Today import androidx.compose.ui.graphics.vector.ImageVector +import com.musfit.R enum class AppDestination( val route: String, - val label: String, + @param:StringRes val labelRes: Int, val icon: ImageVector, val selectedIcon: ImageVector, ) { - Today(route = "today", label = "Today", icon = Icons.Outlined.Today, selectedIcon = Icons.Filled.Today), - Food(route = "food", label = "Food", icon = Icons.Outlined.Restaurant, selectedIcon = Icons.Filled.Restaurant), + Today(route = "today", labelRes = R.string.app_destination_today, icon = Icons.Outlined.Today, selectedIcon = Icons.Filled.Today), + Food(route = "food", labelRes = R.string.app_destination_food, icon = Icons.Outlined.Restaurant, selectedIcon = Icons.Filled.Restaurant), Training( route = "training", - label = "Training", + labelRes = R.string.app_destination_training, icon = Icons.Outlined.FitnessCenter, selectedIcon = Icons.Filled.FitnessCenter, ), - Profile(route = "profile", label = "Profile", icon = Icons.Outlined.Person, selectedIcon = Icons.Filled.Person), + Profile(route = "profile", labelRes = R.string.app_destination_profile, icon = Icons.Outlined.Person, selectedIcon = Icons.Filled.Person), } diff --git a/app/src/main/java/com/musfit/ui/AppNavGraph.kt b/app/src/main/java/com/musfit/ui/AppNavGraph.kt index 5a7db8bb..3c99446f 100644 --- a/app/src/main/java/com/musfit/ui/AppNavGraph.kt +++ b/app/src/main/java/com/musfit/ui/AppNavGraph.kt @@ -47,6 +47,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp @@ -58,6 +60,7 @@ import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.NavDisplay +import com.musfit.R import com.musfit.ui.food.BarcodeScannerScreen import com.musfit.ui.food.FoodNavigation import com.musfit.ui.food.NutritionTrendsScreen @@ -113,6 +116,8 @@ internal fun rootNavigationLayoutForWidth(width: Dp): RootNavigationLayout = whe else -> RootNavigationLayout.Wide } +internal fun compactNavigationShowsLabels(fontScale: Float): Boolean = fontScale < 1.3f + @Composable fun AppNavGraph( barcodeScannerContent: @Composable ( @@ -335,14 +340,14 @@ private fun NavigationSuiteScope.rootNavigationItems( contentDescription = null, ) }, - label = { Text(destination.label) }, + label = { Text(stringResource(destination.labelRes)) }, ) } item( selected = false, onClick = callbacks.onCoachClick, icon = { Icon(Icons.Outlined.ChatBubbleOutline, contentDescription = null) }, - label = { Text("Coach") }, + label = { Text(stringResource(R.string.app_destination_coach)) }, ) } @@ -428,6 +433,8 @@ private fun RowScope.NavBarItem( accent: TabAccent, onClick: () -> Unit, ) { + val destinationLabel = stringResource(destination.labelRes) + val showLabel = compactNavigationShowsLabels(LocalDensity.current.fontScale) val pillColor by animateColorAsState( targetValue = if (selected) accent.container else Color.Transparent, animationSpec = MusFitMotion.effects(), @@ -457,19 +464,21 @@ private fun RowScope.NavBarItem( ) { Icon( imageVector = if (selected) destination.selectedIcon else destination.icon, - contentDescription = destination.label, + contentDescription = destinationLabel, tint = contentColor, modifier = Modifier.size(MusFitBottomNavMetrics.IconSize), ) - Spacer(Modifier.height(MusFitBottomNavMetrics.LabelSpacing)) - Text( - text = destination.label, - style = MaterialTheme.typography.labelSmall, - fontSize = MusFitBottomNavMetrics.LabelSize, - fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium, - color = contentColor, - maxLines = 1, - ) + if (showLabel) { + Spacer(Modifier.height(MusFitBottomNavMetrics.LabelSpacing)) + Text( + text = destinationLabel, + style = MaterialTheme.typography.labelSmall, + fontSize = MusFitBottomNavMetrics.LabelSize, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium, + color = contentColor, + maxLines = 1, + ) + } } } } diff --git a/app/src/main/java/com/musfit/ui/CoachChatEntry.kt b/app/src/main/java/com/musfit/ui/CoachChatEntry.kt index 58d4ba4e..dc198e35 100644 --- a/app/src/main/java/com/musfit/ui/CoachChatEntry.kt +++ b/app/src/main/java/com/musfit/ui/CoachChatEntry.kt @@ -1,8 +1,9 @@ package com.musfit.ui import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.musfit.R import com.musfit.ui.permissions.LOCAL_NETWORK_PERMISSION -import com.musfit.ui.permissions.LOCAL_NETWORK_PERMISSION_DENIED_MESSAGE import com.musfit.ui.permissions.hasLocalNetworkPermission import com.musfit.ui.permissions.requiresLocalNetworkPermission import com.musfit.ui.today.ChatPreviewSheet @@ -18,7 +19,7 @@ internal fun CoachChatEntry( onConfigure = onConfigure, localNetworkConfig = TodayLocalNetworkConfig( permission = LOCAL_NETWORK_PERMISSION, - permissionDeniedMessage = LOCAL_NETWORK_PERMISSION_DENIED_MESSAGE, + permissionDeniedMessage = stringResource(R.string.app_local_network_permission_denied), requiresPermission = ::requiresLocalNetworkPermission, hasPermission = ::hasLocalNetworkPermission, ), diff --git a/app/src/main/java/com/musfit/ui/ProfileSettingsEntry.kt b/app/src/main/java/com/musfit/ui/ProfileSettingsEntry.kt index d4fe7a73..caf7e016 100644 --- a/app/src/main/java/com/musfit/ui/ProfileSettingsEntry.kt +++ b/app/src/main/java/com/musfit/ui/ProfileSettingsEntry.kt @@ -3,9 +3,10 @@ package com.musfit.ui import android.content.Intent import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import com.musfit.BuildConfig +import com.musfit.R import com.musfit.ui.permissions.LOCAL_NETWORK_PERMISSION -import com.musfit.ui.permissions.LOCAL_NETWORK_PERMISSION_DENIED_MESSAGE import com.musfit.ui.permissions.hasLocalNetworkPermission import com.musfit.ui.permissions.requiresLocalNetworkPermission import com.musfit.ui.profile.ProfileSettingsEntryConfig @@ -24,7 +25,7 @@ internal fun ProfileSettingsEntry(onBack: () -> Unit) { googleWebClientId = BuildConfig.GOOGLE_WEB_CLIENT_ID, versionName = BuildConfig.VERSION_NAME, localNetworkPermission = LOCAL_NETWORK_PERMISSION, - localNetworkPermissionDeniedMessage = LOCAL_NETWORK_PERMISSION_DENIED_MESSAGE, + localNetworkPermissionDeniedMessage = stringResource(R.string.app_local_network_permission_denied), requiresLocalNetworkPermission = ::requiresLocalNetworkPermission, hasLocalNetworkPermission = ::hasLocalNetworkPermission, ), diff --git a/app/src/main/java/com/musfit/ui/transfer/DataTransferActivity.kt b/app/src/main/java/com/musfit/ui/transfer/DataTransferActivity.kt index abc7b3a6..2619b756 100644 --- a/app/src/main/java/com/musfit/ui/transfer/DataTransferActivity.kt +++ b/app/src/main/java/com/musfit/ui/transfer/DataTransferActivity.kt @@ -41,15 +41,19 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.musfit.BuildConfig +import com.musfit.R import com.musfit.configureMusFitEdgeToEdge import com.musfit.data.transfer.DataTransferArchiveCodec import com.musfit.ui.components.InnerScreenHeader +import com.musfit.ui.text.asString import com.musfit.ui.theme.MusFitTheme import dagger.hilt.android.AndroidEntryPoint import java.time.LocalDate @@ -103,7 +107,7 @@ private fun DataTransferRoute( if (exportDialog) { PassphraseDialog( - title = "Protect this backup", + title = stringResource(R.string.data_transfer_protect_backup), confirmPassphrase = true, onDismiss = { exportDialog = false }, onConfirm = { passphrase -> @@ -115,7 +119,7 @@ private fun DataTransferRoute( } if (importDialog) { PassphraseDialog( - title = "Unlock MusFit backup", + title = stringResource(R.string.data_transfer_unlock_backup), confirmPassphrase = false, onDismiss = { importDialog = false @@ -149,8 +153,12 @@ private fun DataTransferScreen( verticalArrangement = Arrangement.spacedBy(14.dp), ) { InnerScreenHeader( - title = if (bridgeMode) "Move MusFit data" else "Data transfer", - subtitle = if (bridgeMode) "Step 1 of 3 · export before reinstalling" else "Encrypted, local, and user-controlled", + title = stringResource( + if (bridgeMode) R.string.data_transfer_move_title else R.string.data_transfer_title, + ), + subtitle = stringResource( + if (bridgeMode) R.string.data_transfer_move_subtitle else R.string.data_transfer_subtitle, + ), onBack = onBack, ) Surface( @@ -161,15 +169,17 @@ private fun DataTransferScreen( Column(Modifier.padding(22.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { Icon(Icons.Outlined.Lock, contentDescription = null, modifier = Modifier.size(30.dp)) Text( - if (bridgeMode) "Preserve your existing data" else "Your backup stays yours", + stringResource( + if (bridgeMode) R.string.data_transfer_preserve_title else R.string.data_transfer_backup_yours_title, + ), style = MusFitTheme.typography.headlineSmall, fontWeight = FontWeight.W800, ) Text( if (bridgeMode) { - "Create an encrypted backup, uninstall this legacy build, install the production app, then import the file." + stringResource(R.string.data_transfer_preserve_body) } else { - "MusFit encrypts the database with your passphrase. API keys and Android permissions are never included." + stringResource(R.string.data_transfer_backup_yours_body) }, style = MusFitTheme.typography.bodyMedium, color = MusFitTheme.colors.onSurfaceVariant, @@ -178,16 +188,16 @@ private fun DataTransferScreen( } TransferAction( - title = "Export encrypted backup", - subtitle = "Includes Food, Training, profile, goals, and local history", + title = stringResource(R.string.data_transfer_export_title), + subtitle = stringResource(R.string.data_transfer_export_subtitle), icon = Icons.Outlined.Upload, enabled = !state.busy, onClick = onExport, ) if (!bridgeMode) { TransferAction( - title = "Import encrypted backup", - subtitle = "Verifies checksum and row counts before staging restore", + title = stringResource(R.string.data_transfer_import_title), + subtitle = stringResource(R.string.data_transfer_import_subtitle), icon = Icons.Outlined.Download, enabled = !state.busy, onClick = onImport, @@ -197,28 +207,33 @@ private fun DataTransferScreen( if (state.busy) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 3.dp) - Text("Working locally…", style = MusFitTheme.typography.bodyMedium) + Text(stringResource(R.string.data_transfer_working), style = MusFitTheme.typography.bodyMedium) } } state.message?.let { - Text(it, style = MusFitTheme.typography.bodyMedium, color = MusFitTheme.colors.onSurfaceVariant) + Text(it.asString(), style = MusFitTheme.typography.bodyMedium, color = MusFitTheme.colors.onSurfaceVariant) } state.lastReceipt?.let { receipt -> Text( - "Last restore: ${receipt.totalRows} rows · ${receipt.databaseSha256.take(12)}", + pluralStringResource( + R.plurals.data_transfer_last_restore, + receipt.totalRows.coerceIn(0L, Int.MAX_VALUE.toLong()).toInt(), + receipt.totalRows, + receipt.databaseSha256.take(12), + ), style = MusFitTheme.typography.bodySmall, color = MusFitTheme.colors.onSurfaceVariant, ) } state.lastRestoreFailure?.let { Text( - "The pending restore was not applied; your previous data was retained.", + stringResource(R.string.data_transfer_restore_not_applied), style = MusFitTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, ) } Text( - "Keep the passphrase separately. MusFit cannot recover it. The encrypted file contains personal health and fitness data.", + stringResource(R.string.data_transfer_passphrase_warning), style = MusFitTheme.typography.bodySmall, color = MusFitTheme.colors.onSurfaceFaint, ) @@ -270,11 +285,11 @@ private fun PassphraseDialog( title = { Text(title) }, text = { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text("Use at least 12 characters. It is never stored by MusFit.") + Text(stringResource(R.string.data_transfer_passphrase_help)) OutlinedTextField( value = passphrase, onValueChange = { passphrase = it }, - label = { Text("Passphrase") }, + label = { Text(stringResource(R.string.data_transfer_passphrase)) }, visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), singleLine = true, @@ -283,7 +298,7 @@ private fun PassphraseDialog( OutlinedTextField( value = confirmation, onValueChange = { confirmation = it }, - label = { Text("Confirm passphrase") }, + label = { Text(stringResource(R.string.data_transfer_confirm_passphrase)) }, visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), singleLine = true, @@ -292,8 +307,12 @@ private fun PassphraseDialog( } }, confirmButton = { - TextButton(onClick = { onConfirm(passphrase) }, enabled = valid) { Text("Continue") } + TextButton(onClick = { onConfirm(passphrase) }, enabled = valid) { + Text(stringResource(R.string.data_transfer_continue)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.data_transfer_cancel)) } }, - dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, ) } diff --git a/app/src/main/java/com/musfit/ui/transfer/DataTransferViewModel.kt b/app/src/main/java/com/musfit/ui/transfer/DataTransferViewModel.kt index f88067c0..88ea7acb 100644 --- a/app/src/main/java/com/musfit/ui/transfer/DataTransferViewModel.kt +++ b/app/src/main/java/com/musfit/ui/transfer/DataTransferViewModel.kt @@ -3,22 +3,26 @@ package com.musfit.ui.transfer import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.musfit.R import com.musfit.data.transfer.DataTransferArchiveCodec +import com.musfit.data.transfer.DataTransferReceipt import com.musfit.data.transfer.DataTransferReport import com.musfit.data.transfer.DataTransferRepository -import com.musfit.data.transfer.DataTransferReceipt +import com.musfit.ui.text.UiText +import com.musfit.ui.text.pluralUiText +import com.musfit.ui.text.uiText import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch +import javax.inject.Inject data class DataTransferUiState( val busy: Boolean = false, - val message: String? = null, + val message: UiText? = null, val report: DataTransferReport? = null, val lastReceipt: DataTransferReceipt? = null, val lastRestoreFailure: String? = null, @@ -42,7 +46,12 @@ class DataTransferViewModel @Inject constructor( it.copy( busy = false, report = report, - message = "Encrypted backup saved: ${report.totalRows} rows · ${report.checksumLabel}", + message = pluralUiText( + R.plurals.data_transfer_export_success, + report.totalRows.toQuantity(), + UiText.Argument.LongInteger(report.totalRows), + UiText.Argument.Text(report.checksumLabel), + ), ) } } @@ -53,14 +62,18 @@ class DataTransferViewModel @Inject constructor( it.copy( busy = false, report = report, - message = "Backup verified and staged. Fully close and reopen MusFit to finish restoring ${report.totalRows} rows.", + message = pluralUiText( + R.plurals.data_transfer_import_success, + report.totalRows.toQuantity(), + UiText.Argument.LongInteger(report.totalRows), + ), ) } } private fun runTransfer(passphrase: String, operation: suspend (CharArray) -> Unit) { if (passphrase.length < DataTransferArchiveCodec.MINIMUM_PASSPHRASE_LENGTH) { - _state.update { it.copy(message = "Passphrase must be at least 12 characters.") } + _state.update { it.copy(message = uiText(R.string.data_transfer_short_passphrase)) } return } _state.update { it.copy(busy = true, message = null, report = null) } @@ -74,7 +87,7 @@ class DataTransferViewModel @Inject constructor( _state.update { it.copy( busy = false, - message = "Transfer failed. Check the backup, passphrase, and available storage.", + message = uiText(R.string.data_transfer_failed), ) } } finally { @@ -83,3 +96,5 @@ class DataTransferViewModel @Inject constructor( } } } + +private fun Long.toQuantity(): Int = coerceIn(0L, Int.MAX_VALUE.toLong()).toInt() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bceeedfb..5d94fba9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,78 @@ MusFit + Today + Food + Training + Profile + Coach + Use an HTTPS AI coach endpoint. + + Protect this backup + Unlock MusFit backup + Move MusFit data + Data transfer + Step 1 of 3 · export before reinstalling + Encrypted, local, and user-controlled + Preserve your existing data + Your backup stays yours + Create an encrypted backup, uninstall this legacy build, install the production app, then import the file. + MusFit encrypts the database with your passphrase. API keys and Android permissions are never included. + Export encrypted backup + Includes Food, Training, profile, goals, and local history + Import encrypted backup + Verifies checksum and row counts before staging restore + Working locally… + + Last restore: %1$d row · %2$s + Last restore: %1$d rows · %2$s + + The pending restore was not applied; your previous data was retained. + Keep the passphrase separately. MusFit cannot recover it. The encrypted file contains personal health and fitness data. + Use at least 12 characters. It is never stored by MusFit. + Passphrase + Confirm passphrase + Continue + Cancel + + Encrypted backup saved: %1$d row · %2$s + Encrypted backup saved: %1$d rows · %2$s + + + Backup verified and staged. Fully close and reopen MusFit to finish restoring %1$d row. + Backup verified and staged. Fully close and reopen MusFit to finish restoring %1$d rows. + + Passphrase must be at least 12 characters. + Transfer failed. Check the backup, passphrase, and available storage. + + MusFit Health Connect access + You choose each data type in Health Connect. MusFit uses only the access you grant, and you can change it at any time. + MusFit can read + MusFit can write + • %1$s: %2$s + Steps + Show daily movement and progress. + Active calories + Show energy burned through activity. + Total calories + Show total energy burned during the day. + Distance + Show distance-based activity progress. + Sleep + Show sleep duration in your health summary. + Exercise sessions + Show completed exercise and time spent training. + Weight + Show weight measurements and trends. + Body fat + Show body-fat measurements and trends. + Resting heart rate + Show resting heart-rate trends. + Heart rate variability + Show recovery trends from heart-rate variability. + Workouts + Save workouts you log in MusFit. + Meals and nutrition + Save meals and their nutrition when you choose Food sync. + Water + Save water totals when you choose Food sync. diff --git a/app/src/production/java/com/musfit/ui/permissions/LocalNetworkPermission.kt b/app/src/production/java/com/musfit/ui/permissions/LocalNetworkPermission.kt index 73abd9f6..2d620786 100644 --- a/app/src/production/java/com/musfit/ui/permissions/LocalNetworkPermission.kt +++ b/app/src/production/java/com/musfit/ui/permissions/LocalNetworkPermission.kt @@ -5,7 +5,6 @@ package com.musfit.ui.permissions import android.content.Context const val LOCAL_NETWORK_PERMISSION = "" -const val LOCAL_NETWORK_PERMISSION_DENIED_MESSAGE = "Use an HTTPS AI coach endpoint." fun requiresLocalNetworkPermission(baseUrl: String): Boolean = false diff --git a/app/src/test/java/com/musfit/integrations/healthconnect/HealthPermissionContractTest.kt b/app/src/test/java/com/musfit/integrations/healthconnect/HealthPermissionContractTest.kt index e5669e0e..b056e21e 100644 --- a/app/src/test/java/com/musfit/integrations/healthconnect/HealthPermissionContractTest.kt +++ b/app/src/test/java/com/musfit/integrations/healthconnect/HealthPermissionContractTest.kt @@ -65,12 +65,16 @@ class HealthPermissionContractTest { assertEquals(items.size, items.map { it.permission }.distinct().size) assertEquals(requestedPermissions, items.map { it.permission }.toSet()) - assertEquals(expectedRationales(), items.associate { it.permission to it.expectedRationale() }) + assertEquals( + expectedRationales(), + items.associate { it.permission to it.expectedRationale() }, + ) assertFalse( items.any { item -> + val rationale = item.resolveRationale(context.resources) IMPLEMENTATION_JARGON.any { word -> - item.label.contains(word, ignoreCase = true) || - item.purpose.contains(word, ignoreCase = true) + rationale.label.contains(word, ignoreCase = true) || + rationale.purpose.contains(word, ignoreCase = true) } }, ) @@ -157,11 +161,14 @@ class HealthPermissionContractTest { ), ) - private fun HealthPermissionRationaleItem.expectedRationale() = ExpectedRationale( - access = access, - label = label, - purpose = purpose, - ) + private fun HealthPermissionRationaleItem.expectedRationale(): ExpectedRationale { + val text = resolveRationale(context.resources) + return ExpectedRationale( + access = access, + label = text.label, + purpose = text.purpose, + ) + } private fun healthPermissionsIn(manifest: File): Set { val document = DocumentBuilderFactory.newInstance().apply { diff --git a/app/src/test/java/com/musfit/release/LocalNetworkVariantContractTest.kt b/app/src/test/java/com/musfit/release/LocalNetworkVariantContractTest.kt index ade66e13..5183d8f3 100644 --- a/app/src/test/java/com/musfit/release/LocalNetworkVariantContractTest.kt +++ b/app/src/test/java/com/musfit/release/LocalNetworkVariantContractTest.kt @@ -4,8 +4,8 @@ import android.content.Context import android.os.Build import androidx.test.core.app.ApplicationProvider import com.musfit.BuildConfig +import com.musfit.R import com.musfit.ui.permissions.LOCAL_NETWORK_PERMISSION -import com.musfit.ui.permissions.LOCAL_NETWORK_PERMISSION_DENIED_MESSAGE import com.musfit.ui.permissions.hasLocalNetworkPermission import com.musfit.ui.permissions.requiresLocalNetworkPermission import org.junit.Assert.assertEquals @@ -43,10 +43,11 @@ class LocalNetworkVariantContractTest { @Test fun permissionHelpCopyMatchesTheCurrentVariantPolicy() { + val permissionDeniedMessage = context.getString(R.string.app_local_network_permission_denied) if (BuildConfig.APPLICATION_ID == INTERNAL_APPLICATION_ID) { - assertTrue(LOCAL_NETWORK_PERMISSION_DENIED_MESSAGE.contains("Local Network")) + assertTrue(permissionDeniedMessage.contains("Local Network")) } else { - assertFalse(LOCAL_NETWORK_PERMISSION_DENIED_MESSAGE.contains("LAN", ignoreCase = true)) + assertFalse(permissionDeniedMessage.contains("LAN", ignoreCase = true)) } } diff --git a/app/src/test/java/com/musfit/ui/AppDestinationTest.kt b/app/src/test/java/com/musfit/ui/AppDestinationTest.kt index 78e926ea..8119fd66 100644 --- a/app/src/test/java/com/musfit/ui/AppDestinationTest.kt +++ b/app/src/test/java/com/musfit/ui/AppDestinationTest.kt @@ -1,5 +1,6 @@ package com.musfit.ui +import com.musfit.R import org.junit.Assert.assertEquals import org.junit.Test @@ -12,8 +13,13 @@ class AppDestinationTest { AppDestination.entries.map { it.route }, ) assertEquals( - listOf("Today", "Food", "Training", "Profile"), - AppDestination.entries.map { it.label }, + listOf( + R.string.app_destination_today, + R.string.app_destination_food, + R.string.app_destination_training, + R.string.app_destination_profile, + ), + AppDestination.entries.map { it.labelRes }, ) } diff --git a/app/src/test/java/com/musfit/ui/AppNavigationChromeTest.kt b/app/src/test/java/com/musfit/ui/AppNavigationChromeTest.kt index 380317cf..553acc65 100644 --- a/app/src/test/java/com/musfit/ui/AppNavigationChromeTest.kt +++ b/app/src/test/java/com/musfit/ui/AppNavigationChromeTest.kt @@ -1,5 +1,6 @@ package com.musfit.ui +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -24,4 +25,11 @@ class AppNavigationChromeTest { MusFitBottomNavMetrics.RowBottomPadding.value >= 14f, ) } + + @Test + fun compactNavigation_usesIconOnlyLayoutAtLargeFontScales() { + assertTrue(compactNavigationShowsLabels(1.0f)) + assertFalse(compactNavigationShowsLabels(1.3f)) + assertFalse(compactNavigationShowsLabels(1.5f)) + } } diff --git a/app/src/test/java/com/musfit/ui/transfer/DataTransferViewModelTest.kt b/app/src/test/java/com/musfit/ui/transfer/DataTransferViewModelTest.kt index 21bbab0b..55cb7756 100644 --- a/app/src/test/java/com/musfit/ui/transfer/DataTransferViewModelTest.kt +++ b/app/src/test/java/com/musfit/ui/transfer/DataTransferViewModelTest.kt @@ -1,10 +1,14 @@ package com.musfit.ui.transfer import android.net.Uri +import com.musfit.R import com.musfit.data.transfer.DataTransferReceipt import com.musfit.data.transfer.DataTransferReport import com.musfit.data.transfer.DataTransferRepository import com.musfit.testing.MainDispatcherRule +import com.musfit.ui.text.UiText +import com.musfit.ui.text.pluralUiText +import com.musfit.ui.text.uiText import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest @@ -30,7 +34,15 @@ class DataTransferViewModelTest { viewModel.export(Uri.parse("content://backup/export"), "long migration passphrase") advanceUntilIdle() - assertEquals("Encrypted backup saved: 6 rows · abcdef012345", viewModel.state.value.message) + assertEquals( + pluralUiText( + R.plurals.data_transfer_export_success, + 6, + UiText.Argument.LongInteger(6L), + UiText.Argument.Text("abcdef012345"), + ), + viewModel.state.value.message, + ) assertFalse(viewModel.state.value.busy) assertTrue(repository.receivedSecret?.all { it == '\u0000' } == true) } @@ -42,7 +54,7 @@ class DataTransferViewModelTest { viewModel.export(Uri.EMPTY, "too short") - assertEquals("Passphrase must be at least 12 characters.", viewModel.state.value.message) + assertEquals(uiText(R.string.data_transfer_short_passphrase), viewModel.state.value.message) assertEquals(0, repository.exportCalls) } @@ -55,7 +67,7 @@ class DataTransferViewModelTest { advanceUntilIdle() assertEquals( - "Transfer failed. Check the backup, passphrase, and available storage.", + uiText(R.string.data_transfer_failed), viewModel.state.value.message, ) assertFalse(viewModel.state.value.busy) diff --git a/app/src/testInternalDebug/java/com/musfit/ui/MusFitScreenshotRegressionTest.kt b/app/src/testInternalDebug/java/com/musfit/ui/MusFitScreenshotRegressionTest.kt index 5db76e22..a6e49d38 100644 --- a/app/src/testInternalDebug/java/com/musfit/ui/MusFitScreenshotRegressionTest.kt +++ b/app/src/testInternalDebug/java/com/musfit/ui/MusFitScreenshotRegressionTest.kt @@ -117,6 +117,16 @@ class MusFitScreenshotRegressionTest { RootNavigationFixture(RootNavigationLayout.Compact) } + @Test + @Config(qualifiers = "en-rXA-w400dp-h800dp-mdpi") + fun navigation_phone_pseudo_largeFont() = capture( + "navigation-phone-pseudo-font-150.png", + dark = false, + fontScale = 1.5f, + ) { + RootNavigationFixture(RootNavigationLayout.Compact) + } + @Test @Config(qualifiers = "w610dp-h900dp-mdpi") fun navigation_foldable_dark_rtl() = capture( @@ -127,6 +137,16 @@ class MusFitScreenshotRegressionTest { RootNavigationFixture(RootNavigationLayout.Rail) } + @Test + @Config(qualifiers = "ar-rXB-w610dp-h900dp-mdpi") + fun navigation_foldable_pseudoRtl() = capture( + "navigation-foldable-pseudo-rtl.png", + dark = false, + rtl = true, + ) { + RootNavigationFixture(RootNavigationLayout.Rail) + } + @Test @Config(qualifiers = "w900dp-h700dp-mdpi") fun navigation_tablet_light_ltr() = capture("navigation-tablet-light-ltr.png", dark = false) { diff --git a/app/src/testInternalDebug/screenshots/navigation-foldable-pseudo-rtl.png b/app/src/testInternalDebug/screenshots/navigation-foldable-pseudo-rtl.png new file mode 100644 index 0000000000000000000000000000000000000000..dc9ec865400102133f49a6ce3b39e861d79cd951 GIT binary patch literal 12781 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 H{Ow->?dDAR literal 0 HcmV?d00001 diff --git a/app/src/testInternalDebug/screenshots/navigation-phone-pseudo-font-150.png b/app/src/testInternalDebug/screenshots/navigation-phone-pseudo-font-150.png new file mode 100644 index 0000000000000000000000000000000000000000..07deda41c7d3eddf7d11d0f3d85e4aa80b33797a GIT binary patch literal 16309 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 H{ObPzMju1_ literal 0 HcmV?d00001 diff --git a/core/designsystem/src/main/kotlin/com/musfit/ui/text/LocalizedFormatter.kt b/core/designsystem/src/main/kotlin/com/musfit/ui/text/LocalizedFormatter.kt index 8f76b110..ae6a3fbd 100644 --- a/core/designsystem/src/main/kotlin/com/musfit/ui/text/LocalizedFormatter.kt +++ b/core/designsystem/src/main/kotlin/com/musfit/ui/text/LocalizedFormatter.kt @@ -25,8 +25,10 @@ object LocalizedFormatter { value: Long, grouping: Boolean = true, locale: Locale = Locale.getDefault(), + minimumIntegerDigits: Int = 1, ): String = NumberFormat.getIntegerInstance(locale).run { isGroupingUsed = grouping + this.minimumIntegerDigits = minimumIntegerDigits format(value) } diff --git a/core/designsystem/src/test/kotlin/com/musfit/ui/text/LocalizedFormatterTest.kt b/core/designsystem/src/test/kotlin/com/musfit/ui/text/LocalizedFormatterTest.kt index 56a3c6fb..68b6baf2 100644 --- a/core/designsystem/src/test/kotlin/com/musfit/ui/text/LocalizedFormatterTest.kt +++ b/core/designsystem/src/test/kotlin/com/musfit/ui/text/LocalizedFormatterTest.kt @@ -26,4 +26,17 @@ class LocalizedFormatterTest { LocalizedFormatter.date(LocalDate.of(2026, 7, 20), locale = Locale.GERMANY), ) } + + @Test + fun `display integers support locale-aware zero padding`() { + assertEquals( + "08", + LocalizedFormatter.integer( + 8, + grouping = false, + locale = Locale.US, + minimumIntegerDigits = 2, + ), + ) + } } diff --git a/core/model/src/main/kotlin/com/musfit/domain/today/TodayMetrics.kt b/core/model/src/main/kotlin/com/musfit/domain/today/TodayMetrics.kt index 92f65e58..ffea7225 100644 --- a/core/model/src/main/kotlin/com/musfit/domain/today/TodayMetrics.kt +++ b/core/model/src/main/kotlin/com/musfit/domain/today/TodayMetrics.kt @@ -1,26 +1,22 @@ package com.musfit.domain.today -import java.util.Locale -import kotlin.math.abs -import kotlin.math.roundToInt - -/** The pool of pinnable Today-carousel metrics. [id] is the persisted identity. */ -enum class TodayMetric(val id: String, val label: String) { - Calories("calories", "Calories"), - Protein("protein", "Protein"), - Carbs("carbs", "Carbs"), - Fat("fat", "Fat"), - Water("water", "Water"), - Steps("steps", "Steps"), - Weight("weight", "Weight"), - BodyFat("body_fat", "Body fat"), - Sessions("sessions", "Sessions"), - Sleep("sleep", "Sleep"), - Exercise("exercise", "Exercise"), - ActiveCalories("active_calories", "Active kcal"), - RestingHeartRate("resting_hr", "Resting HR"), - CalorieBalance("calorie_balance", "Balance"), - LoggingStreak("streak", "Streak"), +/** The pool of pinnable Today metrics. [id] is the persisted, nonlocalized identity. */ +enum class TodayMetric(val id: String) { + Calories("calories"), + Protein("protein"), + Carbs("carbs"), + Fat("fat"), + Water("water"), + Steps("steps"), + Weight("weight"), + BodyFat("body_fat"), + Sessions("sessions"), + Sleep("sleep"), + Exercise("exercise"), + ActiveCalories("active_calories"), + RestingHeartRate("resting_hr"), + CalorieBalance("calorie_balance"), + LoggingStreak("streak"), ; companion object { @@ -47,16 +43,7 @@ object LoggingStreakCalculator { } } -/** The three display states every carousel metric resolves to. */ -sealed interface MetricValue { - data class WithGoal(val figure: String, val caption: String, val progress: Float) : MetricValue - - data class Plain(val figure: String, val caption: String) : MetricValue - - data class NoData(val caption: String) : MetricValue -} - -/** Everything the resolver needs, assembled by the ViewModel from live flows. */ +/** Locale-neutral measurements assembled by the ViewModel for presentation. */ data class MetricSnapshot( val caloriesKcal: Double, val calorieGoalKcal: Double, @@ -80,168 +67,12 @@ data class MetricSnapshot( val sleepMinutes: Long?, val exerciseMinutes: Long?, val exerciseSessionCount: Int?, - val restingHeartRateBpm: Long?, // Long to match DailyHealthSummaryEntity + val restingHeartRateBpm: Long?, val loggingStreakDays: Int, ) -/** Pure mapping of one metric + snapshot → display state (spec §1 formulas). */ -object MetricResolver { - fun resolve(metric: TodayMetric, s: MetricSnapshot): MetricValue = when (metric) { - TodayMetric.Calories -> - if (s.calorieGoalKcal > 0.0) { - // Turn 8 vitals tile: the EATEN figure with the full goal spelled - // out ("of 2,450 kcal", never "2.5k") and the unclamped percent. - MetricValue.WithGoal( - figure = formatGrouped(s.caloriesKcal.roundToInt()), - caption = "of ${formatGrouped(s.calorieGoalKcal.roundToInt())} kcal · ${percent(s.caloriesKcal, s.calorieGoalKcal)}%", - progress = ratio(s.caloriesKcal, s.calorieGoalKcal), - ) - } else { - MetricValue.Plain(formatGrouped(s.caloriesKcal.roundToInt()), "kcal eaten") - } - - TodayMetric.Protein -> gramMetric(s.proteinGrams, s.proteinGoalGrams) - - TodayMetric.Carbs -> gramMetric(s.carbsGrams, s.carbsGoalGrams) - - TodayMetric.Fat -> gramMetric(s.fatGrams, s.fatGoalGrams) - - TodayMetric.Water -> - if (s.waterGoalMl > 0.0) { - MetricValue.WithGoal( - figure = formatLiters(s.waterMl), - caption = "of ${formatLiters(s.waterGoalMl)} · ${percent(s.waterMl, s.waterGoalMl)}%", - progress = ratio(s.waterMl, s.waterGoalMl), - ) - } else { - MetricValue.Plain(formatLiters(s.waterMl), "today") - } - - TodayMetric.Steps -> { - val steps = s.steps ?: return MetricValue.NoData("Not connected") - if (s.stepGoal > 0L) { - MetricValue.WithGoal( - figure = formatCount(steps), - caption = "of ${formatCount(s.stepGoal)} · ${percent(steps.toDouble(), s.stepGoal.toDouble())}%", - progress = ratio(steps.toDouble(), s.stepGoal.toDouble()), - ) - } else { - MetricValue.Plain(formatCount(steps), "steps") - } - } - - TodayMetric.Weight -> { - val weight = s.latestWeightKg ?: return MetricValue.NoData("No data") - val caption = s.weightDeltaKg?.let { delta -> - val sign = if (delta < 0) "−" else "+" - "$sign${abs(delta).format1()} kg · 7d" - } ?: "latest" - MetricValue.Plain("${weight.format1()} kg", caption) - } - - TodayMetric.BodyFat -> { - val bodyFat = s.bodyFatPercent ?: return MetricValue.NoData("No data") - val caption = s.bodyFatDelta?.let { delta -> - val sign = if (delta < 0) "−" else "+" - "$sign${abs(delta).format1()} pts" - } ?: "latest" - MetricValue.Plain("${bodyFat.format1()}%", caption) - } - - TodayMetric.Sessions -> - if (s.sessionTarget > 0) { - MetricValue.WithGoal( - figure = "${s.sessionsDone}/${s.sessionTarget}", - caption = "this week", - progress = ratio(s.sessionsDone.toDouble(), s.sessionTarget.toDouble()), - ) - } else { - MetricValue.Plain(s.sessionsDone.toString(), "this week") - } - - TodayMetric.Sleep -> { - val minutes = s.sleepMinutes ?: return MetricValue.NoData("Not connected") - MetricValue.Plain(formatDuration(minutes), "sleep") - } - - TodayMetric.Exercise -> { - val minutes = s.exerciseMinutes ?: return MetricValue.NoData("Not connected") - val caption = when (val count = s.exerciseSessionCount) { - null -> "exercise" - 1 -> "1 session" - else -> "$count sessions" - } - MetricValue.Plain(formatDuration(minutes), caption) - } - - TodayMetric.ActiveCalories -> { - val active = s.activeCaloriesKcal ?: return MetricValue.NoData("Not connected") - MetricValue.Plain(active.roundToInt().toString(), "active kcal") - } - - TodayMetric.RestingHeartRate -> { - val bpm = s.restingHeartRateBpm ?: return MetricValue.NoData("Not connected") - MetricValue.Plain("$bpm bpm", "resting") - } - - TodayMetric.CalorieBalance -> { - val active = s.activeCaloriesKcal ?: return MetricValue.NoData("Not connected") - MetricValue.Plain((s.caloriesKcal - active).roundToInt().toString(), "kcal in − out") - } - - TodayMetric.LoggingStreak -> - MetricValue.Plain("${s.loggingStreakDays} d", "logging streak") - } - - private fun gramMetric(grams: Double, goalGrams: Double): MetricValue = if (goalGrams > 0.0) { - MetricValue.WithGoal( - figure = "${grams.roundToInt()} g", - caption = "of ${goalGrams.roundToInt()} g · ${percent(grams, goalGrams)}%", - progress = ratio(grams, goalGrams), - ) - } else { - MetricValue.Plain("${grams.roundToInt()} g", "today") - } - - private fun ratio(value: Double, goal: Double): Float = if (goal <= 0.0) 0f else (value / goal).coerceIn(0.0, 1.0).toFloat() - - /** Unclamped display percent — over-goal reads "112%", the wave still caps at full. */ - private fun percent(value: Double, goal: Double): Int = (value / goal * 100.0).roundToInt() - - private fun formatGrouped(value: Int): String = String.format(Locale.US, "%,d", value) - - private fun formatCount(value: Long): String = String.format(Locale.US, "%,d", value).replace(',', '.') - - /** Water reads in liters on the vitals tile ("1.5 L"); whole liters drop the decimal. */ - private fun formatLiters(milliliters: Double): String { - val liters = milliliters / 1000.0 - val text = if (liters % 1.0 == 0.0) liters.toInt().toString() else String.format(Locale.US, "%.1f", liters) - return "$text L" - } - - private fun formatDuration(minutes: Long): String { - val safeMinutes = minutes.coerceAtLeast(0L) - val hours = safeMinutes / 60L - val remainder = safeMinutes % 60L - return if (hours > 0L) { - "${hours}h ${remainder.toString().padStart(2, '0')}m" - } else { - "${remainder}m" - } - } - - private fun Double.format1(): String = if (this % 1.0 == 0.0) toInt().toString() else String.format(Locale.US, "%.1f", this) -} - -/** - * The Turn 8 vitals grid replaces the hero carousel: every pinned metric is one - * tile, rendered two per row in pin order. Empty pins fall back to the default - * four (eaten kcal, steps, protein, water). - */ +/** Every pinned metric is one tile, rendered two per row in pin order. */ fun vitalsTileMetrics(pins: List): List = pins.ifEmpty { TodayMetric.DEFAULT_PINS } -/** - * Sessions-metric week: Monday-start, [weekStartMillis, weekStartMillis + 7 days) — - * Today's existing convention (recorded deviation #3), pinned here by test. - */ +/** Sessions-metric week: Monday-start, [weekStartMillis, weekStartMillis + 7 days). */ fun countSessionsInWeek(sessionStartMillis: List, weekStartMillis: Long): Int = sessionStartMillis.count { it >= weekStartMillis && it < weekStartMillis + 7 * 86_400_000L } diff --git a/core/model/src/test/kotlin/com/musfit/domain/today/TodayMetricsTest.kt b/core/model/src/test/kotlin/com/musfit/domain/today/TodayMetricsTest.kt index 3e8194b4..167a708d 100644 --- a/core/model/src/test/kotlin/com/musfit/domain/today/TodayMetricsTest.kt +++ b/core/model/src/test/kotlin/com/musfit/domain/today/TodayMetricsTest.kt @@ -5,180 +5,6 @@ import org.junit.Assert.assertTrue import org.junit.Test class TodayMetricsTest { - private fun snapshot( - caloriesKcal: Double = 1200.0, - calorieGoalKcal: Double = 2000.0, - proteinGrams: Double = 80.0, - proteinGoalGrams: Double = 150.0, - carbsGrams: Double = 130.0, - carbsGoalGrams: Double = 200.0, - fatGrams: Double = 40.0, - fatGoalGrams: Double = 60.0, - waterMl: Double = 1000.0, - waterGoalMl: Double = 2000.0, - steps: Long? = 8200L, - stepGoal: Long = 10_000L, - latestWeightKg: Double? = 82.4, - weightDeltaKg: Double? = -0.4, - bodyFatPercent: Double? = 18.5, - bodyFatDelta: Double? = -0.3, - sessionsDone: Int = 2, - sessionTarget: Int = 4, - activeCaloriesKcal: Double? = 420.0, - sleepMinutes: Long? = 465L, - exerciseMinutes: Long? = 68L, - exerciseSessionCount: Int? = 2, - restingHeartRateBpm: Long? = 58L, // matches DailyHealthSummaryEntity.restingHeartRateBpm: Long? - loggingStreakDays: Int = 5, - ) = MetricSnapshot( - caloriesKcal = caloriesKcal, - calorieGoalKcal = calorieGoalKcal, - proteinGrams = proteinGrams, - proteinGoalGrams = proteinGoalGrams, - carbsGrams = carbsGrams, - carbsGoalGrams = carbsGoalGrams, - fatGrams = fatGrams, - fatGoalGrams = fatGoalGrams, - waterMl = waterMl, - waterGoalMl = waterGoalMl, - steps = steps, - stepGoal = stepGoal, - latestWeightKg = latestWeightKg, - weightDeltaKg = weightDeltaKg, - bodyFatPercent = bodyFatPercent, - bodyFatDelta = bodyFatDelta, - sessionsDone = sessionsDone, - sessionTarget = sessionTarget, - activeCaloriesKcal = activeCaloriesKcal, - sleepMinutes = sleepMinutes, - exerciseMinutes = exerciseMinutes, - exerciseSessionCount = exerciseSessionCount, - restingHeartRateBpm = restingHeartRateBpm, - loggingStreakDays = loggingStreakDays, - ) - - @Test - fun calories_withGoalShowsEatenOfGoalWithPercent() { - // Turn 8 vitals tile: the EATEN figure with the full goal and percent in the sub line. - val value = MetricResolver.resolve(TodayMetric.Calories, snapshot()) as MetricValue.WithGoal - assertEquals("1,200", value.figure) - assertEquals("of 2,000 kcal · 60%", value.caption) - assertEquals(0.6f, value.progress, 0.001f) - } - - @Test - fun calories_withoutGoalShowsEaten() { - val value = MetricResolver.resolve(TodayMetric.Calories, snapshot(calorieGoalKcal = 0.0)) - assertEquals(MetricValue.Plain("1,200", "kcal eaten"), value) - } - - @Test - fun calories_overGoalReportsPercentPastHundredWithFullWave() { - val value = MetricResolver.resolve( - TodayMetric.Calories, - snapshot(caloriesKcal = 3500.0, calorieGoalKcal = 2000.0), - ) as MetricValue.WithGoal - assertEquals("3,500", value.figure) - assertEquals("of 2,000 kcal · 175%", value.caption) - assertEquals(1.0f, value.progress, 0.001f) - } - - @Test - fun steps_nullMeansNotConnected() { - val value = MetricResolver.resolve(TodayMetric.Steps, snapshot(steps = null)) - assertEquals(MetricValue.NoData("Not connected"), value) - } - - @Test - fun steps_withGoalUsesFullDotGroupedCounts() { - val value = MetricResolver.resolve( - TodayMetric.Steps, - snapshot(steps = 5_500L, stepGoal = 10_000L), - ) as MetricValue.WithGoal - - assertEquals("5.500", value.figure) - assertEquals("of 10.000 · 55%", value.caption) - } - - @Test - fun protein_withGoalShowsGramsOfGoalWithPercent() { - val value = MetricResolver.resolve(TodayMetric.Protein, snapshot()) as MetricValue.WithGoal - assertEquals("80 g", value.figure) - assertEquals("of 150 g · 53%", value.caption) - } - - @Test - fun water_withGoalShowsLitersOfGoalWithPercent() { - val value = MetricResolver.resolve( - TodayMetric.Water, - snapshot(waterMl = 1500.0, waterGoalMl = 2500.0), - ) as MetricValue.WithGoal - assertEquals("1.5 L", value.figure) - assertEquals("of 2.5 L · 60%", value.caption) - assertEquals(0.6f, value.progress, 0.001f) - } - - @Test - fun water_wholeLitersDropTheDecimal() { - val value = MetricResolver.resolve( - TodayMetric.Water, - snapshot(waterMl = 2000.0, waterGoalMl = 2000.0), - ) as MetricValue.WithGoal - assertEquals("2 L", value.figure) - assertEquals("of 2 L · 100%", value.caption) - } - - @Test - fun weight_showsLatestWithDeltaAndNoDataWhenNeverLogged() { - val value = MetricResolver.resolve(TodayMetric.Weight, snapshot()) as MetricValue.Plain - assertEquals("82.4 kg", value.figure) - assertEquals("−0.4 kg · 7d", value.caption) - - val empty = MetricResolver.resolve(TodayMetric.Weight, snapshot(latestWeightKg = null)) - assertEquals(MetricValue.NoData("No data"), empty) - } - - @Test - fun calorieBalance_needsHealthConnect() { - val value = MetricResolver.resolve(TodayMetric.CalorieBalance, snapshot()) as MetricValue.Plain - assertEquals("780", value.figure) // 1200 eaten − 420 active - assertEquals("kcal in − out", value.caption) - - val noHc = MetricResolver.resolve(TodayMetric.CalorieBalance, snapshot(activeCaloriesKcal = null)) - assertEquals(MetricValue.NoData("Not connected"), noHc) - } - - @Test - fun sessions_showsDoneVsTarget() { - val value = MetricResolver.resolve(TodayMetric.Sessions, snapshot()) as MetricValue.WithGoal - assertEquals("2/4", value.figure) - assertEquals("this week", value.caption) - assertEquals(0.5f, value.progress, 0.001f) - } - - @Test - fun sleepAndExercise_showHealthConnectAggregates() { - val sleep = MetricResolver.resolve(TodayMetric.Sleep, snapshot()) as MetricValue.Plain - assertEquals("7h 45m", sleep.figure) - assertEquals("sleep", sleep.caption) - - val exercise = MetricResolver.resolve(TodayMetric.Exercise, snapshot()) as MetricValue.Plain - assertEquals("1h 08m", exercise.figure) - assertEquals("2 sessions", exercise.caption) - } - - @Test - fun remainingMetrics_noDataAndNoGoalBranches() { - assertEquals(MetricValue.Plain("80 g", "today"), MetricResolver.resolve(TodayMetric.Protein, snapshot(proteinGoalGrams = 0.0))) - assertEquals(MetricValue.Plain("1 L", "today"), MetricResolver.resolve(TodayMetric.Water, snapshot(waterGoalMl = 0.0))) - assertEquals(MetricValue.NoData("No data"), MetricResolver.resolve(TodayMetric.BodyFat, snapshot(bodyFatPercent = null))) - assertEquals(MetricValue.NoData("Not connected"), MetricResolver.resolve(TodayMetric.ActiveCalories, snapshot(activeCaloriesKcal = null))) - assertEquals(MetricValue.NoData("Not connected"), MetricResolver.resolve(TodayMetric.Sleep, snapshot(sleepMinutes = null))) - assertEquals(MetricValue.NoData("Not connected"), MetricResolver.resolve(TodayMetric.Exercise, snapshot(exerciseMinutes = null))) - assertEquals(MetricValue.NoData("Not connected"), MetricResolver.resolve(TodayMetric.RestingHeartRate, snapshot(restingHeartRateBpm = null))) - assertEquals(MetricValue.Plain("58 bpm", "resting"), MetricResolver.resolve(TodayMetric.RestingHeartRate, snapshot())) - } - @Test fun sessions_weekBoundaryIsMondayStartInclusiveExclusive() { val weekStart = 1_000_000L diff --git a/feature/today/build.gradle.kts b/feature/today/build.gradle.kts index 6c5b5289..dfa6a34e 100644 --- a/feature/today/build.gradle.kts +++ b/feature/today/build.gradle.kts @@ -15,7 +15,10 @@ android { create("legacyMigration") { dimension = "distribution" } } buildTypes { - debug { enableUnitTestCoverage = true } + debug { + enableUnitTestCoverage = true + isPseudoLocalesEnabled = true + } create("benchmark") { initWith(getByName("release")) matchingFallbacks += "release" diff --git a/feature/today/src/main/java/com/musfit/ui/today/CoachChatViewModel.kt b/feature/today/src/main/java/com/musfit/ui/today/CoachChatViewModel.kt index 6b176021..a4236678 100644 --- a/feature/today/src/main/java/com/musfit/ui/today/CoachChatViewModel.kt +++ b/feature/today/src/main/java/com/musfit/ui/today/CoachChatViewModel.kt @@ -20,6 +20,9 @@ import com.musfit.data.repository.UserGoals import com.musfit.data.repository.UserProfile import com.musfit.data.repository.WorkoutHistorySummary import com.musfit.domain.model.NutritionTotals +import com.musfit.feature.today.R +import com.musfit.ui.text.UiText +import com.musfit.ui.text.uiText import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -39,9 +42,9 @@ data class CoachChatUiState( val input: String = "", val isSending: Boolean = false, val isConfigured: Boolean = false, - val providerLabel: String = "Off", + val providerLabel: UiText = uiText(R.string.today_coach_off), val baseUrl: String = "", - val errorMessage: String? = null, + val errorMessage: UiText? = null, ) @HiltViewModel @@ -87,7 +90,7 @@ class CoachChatViewModel @Inject constructor( aiCoachChatRepository.sendMessage(prompt, buildSystemPrompt()) }.onFailure { error -> mutableState.update { - it.copy(errorMessage = error.message ?: "Coach is not reachable.") + it.copy(errorMessage = error.message?.let(UiText::Verbatim) ?: uiText(R.string.today_error_coach_not_reachable)) } } mutableState.update { it.copy(isSending = false) } @@ -98,14 +101,16 @@ class CoachChatViewModel @Inject constructor( viewModelScope.launch { runCatching { aiCoachChatRepository.clearThread() } .onFailure { error -> - mutableState.update { it.copy(errorMessage = error.message ?: "Could not clear coach chat.") } + mutableState.update { + it.copy(errorMessage = error.message?.let(UiText::Verbatim) ?: uiText(R.string.today_error_clear_chat)) + } } } } fun reportLocalNetworkPermissionDenied(message: String) { mutableState.update { - it.copy(errorMessage = message) + it.copy(errorMessage = UiText.Verbatim(message)) } } @@ -114,7 +119,9 @@ class CoachChatViewModel @Inject constructor( viewModelScope.launch { runCatching { aiCoachChatRepository.testConnection() } .onFailure { error -> - mutableState.update { it.copy(errorMessage = error.message ?: "Coach is not reachable.") } + mutableState.update { + it.copy(errorMessage = error.message?.let(UiText::Verbatim) ?: uiText(R.string.today_error_coach_not_reachable)) + } } } } @@ -179,15 +186,15 @@ internal fun coachSystemPrompt( return lines.joinToString("\n") } -private fun AiCoachProviderKind.chatLabel(localAgentKind: LocalAgentKind): String = when (this) { - AiCoachProviderKind.Disabled -> "Off" +private fun AiCoachProviderKind.chatLabel(localAgentKind: LocalAgentKind): UiText = when (this) { + AiCoachProviderKind.Disabled -> uiText(R.string.today_coach_off) - AiCoachProviderKind.OpenAiCompatible -> "API coach" + AiCoachProviderKind.OpenAiCompatible -> uiText(R.string.today_api_coach) AiCoachProviderKind.LocalAgent -> when (localAgentKind) { - LocalAgentKind.HermesAgent -> "Hermes" - LocalAgentKind.OpenClaw -> "OpenClaw" - LocalAgentKind.Custom -> "Local agent" + LocalAgentKind.HermesAgent -> uiText(R.string.today_hermes) + LocalAgentKind.OpenClaw -> uiText(R.string.today_openclaw) + LocalAgentKind.Custom -> uiText(R.string.today_local_agent) } } diff --git a/feature/today/src/main/java/com/musfit/ui/today/CoachFeedUi.kt b/feature/today/src/main/java/com/musfit/ui/today/CoachFeedUi.kt index 4f5af2e5..0fe64d5f 100644 --- a/feature/today/src/main/java/com/musfit/ui/today/CoachFeedUi.kt +++ b/feature/today/src/main/java/com/musfit/ui/today/CoachFeedUi.kt @@ -61,7 +61,9 @@ import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.CustomAccessibilityAction import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription @@ -79,7 +81,9 @@ import com.musfit.data.repository.AiCoachChatRole import com.musfit.data.repository.CoachMessage import com.musfit.domain.coach.CoachAction import com.musfit.domain.coach.CoachMessageCategory +import com.musfit.feature.today.R import com.musfit.ui.components.groupedShape +import com.musfit.ui.text.asString import com.musfit.ui.theme.BrandCoral import com.musfit.ui.theme.MusFitTheme import java.time.Instant @@ -88,7 +92,7 @@ import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.util.Locale -private fun currentTimeFormatter(): DateTimeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(Locale.getDefault()) +private fun currentTimeFormatter(locale: Locale): DateTimeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale) /** * The feed body: day-grouped coach messages, newest first, each group rendered @@ -100,10 +104,11 @@ fun CoachFeed( onAction: (CoachAction) -> Unit, onDismiss: (Long) -> Unit, ) { + val locale = LocalConfiguration.current.locales[0] Column(verticalArrangement = Arrangement.spacedBy(MusFitTheme.spacing.xs)) { groups.forEach { group -> Text( - text = group.label.uppercase(), + text = group.label.asString().uppercase(locale), style = MusFitTheme.typography.labelSmall, color = MusFitTheme.colors.onSurfaceVariant, modifier = Modifier.padding(top = MusFitTheme.spacing.sm, bottom = MusFitTheme.spacing.xs), @@ -155,6 +160,8 @@ private fun CoachMessageCard( ) { // White cards on the cream ground; the coral accent carries the overline, // the unread dot, and the tonal action pill. + val unread = stringResource(R.string.today_unread) + val dismissMessage = stringResource(R.string.today_dismiss_message) Surface( color = MusFitTheme.colors.surface, shape = shape, @@ -165,9 +172,9 @@ private fun CoachMessageCard( detectTapGestures(onLongPress = { onLongPress() }) } .semantics(mergeDescendants = true) { - if (!message.isRead) stateDescription = "Unread" + if (!message.isRead) stateDescription = unread customActions = listOf( - CustomAccessibilityAction(label = "Dismiss message") { + CustomAccessibilityAction(label = dismissMessage) { onLongPress() true }, @@ -198,6 +205,7 @@ private fun CoachMessageCard( @Composable private fun CoachMessageHeader(message: CoachMessage) { + val locale = LocalConfiguration.current.locales[0] Row(verticalAlignment = Alignment.CenterVertically) { Icon( message.category.icon(), @@ -207,13 +215,13 @@ private fun CoachMessageHeader(message: CoachMessage) { ) Spacer(Modifier.width(6.dp)) Text( - text = message.category.displayLabel().uppercase(), + text = message.category.displayLabel().uppercase(locale), style = MusFitTheme.typography.labelSmall, color = MusFitTheme.colors.accent, ) Spacer(Modifier.weight(1f)) Text( - text = currentTimeFormatter().format( + text = currentTimeFormatter(locale).format( Instant.ofEpochMilli(message.firstSeenAtEpochMillis).atZone(ZoneId.systemDefault()), ), style = MusFitTheme.typography.labelSmall, @@ -259,11 +267,12 @@ private fun CoachActionButton( } } +@Composable internal fun coachActionLabel(action: CoachAction): String = when (action) { - CoachAction.OpenFood -> "Open Food" - CoachAction.OpenTraining -> "Open Training" - CoachAction.OpenHealth -> "Open Profile" - is CoachAction.StartRoutine -> "Start workout" + CoachAction.OpenFood -> stringResource(R.string.today_open_food) + CoachAction.OpenTraining -> stringResource(R.string.today_open_training) + CoachAction.OpenHealth -> stringResource(R.string.today_open_profile) + is CoachAction.StartRoutine -> stringResource(R.string.today_start_workout) } /** @@ -278,13 +287,14 @@ internal fun coachActionDestination(action: CoachAction): TodayNavigationTarget is CoachAction.StartRoutine -> TodayNavigationTarget.Training } +@Composable private fun CoachMessageCategory.displayLabel(): String = when (this) { - CoachMessageCategory.Plan -> "Plan" - CoachMessageCategory.Nutrition -> "Nutrition" - CoachMessageCategory.Training -> "Training" - CoachMessageCategory.Trend -> "Trend" - CoachMessageCategory.Achievement -> "Achievement" - CoachMessageCategory.Recap -> "Recap" + CoachMessageCategory.Plan -> stringResource(R.string.today_category_plan) + CoachMessageCategory.Nutrition -> stringResource(R.string.today_category_nutrition) + CoachMessageCategory.Training -> stringResource(R.string.today_category_training) + CoachMessageCategory.Trend -> stringResource(R.string.today_category_trend) + CoachMessageCategory.Achievement -> stringResource(R.string.today_category_achievement) + CoachMessageCategory.Recap -> stringResource(R.string.today_category_recap) } private fun CoachMessageCategory.icon(): ImageVector = when (this) { @@ -326,7 +336,7 @@ fun ChatPreviewFab(onClick: () -> Unit, modifier: Modifier = Modifier) { Box(modifier = Modifier.size(28.dp)) { Icon( Icons.Filled.ChatBubble, - contentDescription = "Ask coach", + contentDescription = stringResource(R.string.today_ask_coach), tint = Color.White, modifier = Modifier.size(28.dp), ) @@ -400,13 +410,13 @@ fun ChatPreviewSheet( } Column(modifier = Modifier.weight(1f)) { Text( - text = "Ask coach", + text = stringResource(R.string.today_ask_coach), style = MusFitTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = MusFitTheme.colors.onSurface, ) Text( - text = state.providerLabel, + text = state.providerLabel.asString(), style = MusFitTheme.typography.bodySmall, color = MusFitTheme.colors.onSurfaceVariant, ) @@ -416,21 +426,21 @@ fun ChatPreviewSheet( enabled = state.messages.isNotEmpty(), modifier = Modifier.size(48.dp).semantics { role = Role.Button }, ) { - Icon(Icons.Outlined.DeleteOutline, contentDescription = "Clear coach chat") + Icon(Icons.Outlined.DeleteOutline, contentDescription = stringResource(R.string.today_clear_coach_chat)) } IconButton( onClick = onDismiss, modifier = Modifier.size(48.dp).semantics { role = Role.Button }, ) { - Icon(Icons.Outlined.Close, contentDescription = "Close coach chat") + Icon(Icons.Outlined.Close, contentDescription = stringResource(R.string.today_close_coach_chat)) } } if (!state.isConfigured) { CoachChatEmptyState( - title = "Coach connection is off", - body = "Choose Hermes or another endpoint in Profile settings.", - actionLabel = "Configure", + title = stringResource(R.string.today_coach_connection_off), + body = stringResource(R.string.today_configure_coach_body), + actionLabel = stringResource(R.string.today_configure), onAction = { onDismiss() onConfigure() @@ -450,7 +460,7 @@ fun ChatPreviewSheet( horizontalArrangement = Arrangement.spacedBy(10.dp), ) { Text( - text = message, + text = message.asString(), style = MusFitTheme.typography.bodySmall, color = MusFitTheme.colors.onSurfaceVariant, modifier = Modifier.weight(1f), @@ -458,7 +468,7 @@ fun ChatPreviewSheet( TextButton( onClick = { runWithLocalNetworkPermission(viewModel::testConnection) }, ) { - Text("Retry") + Text(stringResource(R.string.today_retry)) } } } @@ -482,6 +492,10 @@ internal fun CoachChatComposer( onSend: () -> Unit, modifier: Modifier = Modifier, ) { + val coachMessageDescription = stringResource(R.string.today_coach_message) + val sendDescription = stringResource( + if (isSending) R.string.today_sending_coach_message else R.string.today_send_coach_message, + ) Row( modifier = modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -490,50 +504,56 @@ internal fun CoachChatComposer( OutlinedTextField( value = input, onValueChange = onInputChanged, - placeholder = { Text("Ask about today") }, + placeholder = { Text(stringResource(R.string.today_ask_about_today)) }, singleLine = false, minLines = 1, maxLines = 4, modifier = Modifier .weight(1f) - .semantics { contentDescription = "Coach message" }, + .semantics { contentDescription = coachMessageDescription }, ) val canSend = input.isNotBlank() && !isSending - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .size(48.dp) - .clip(CircleShape) - .clickable( - enabled = canSend, - role = Role.Button, - onClick = onSend, - ) - .semantics { - role = Role.Button - contentDescription = if (isSending) "Sending coach message" else "Send coach message" - }, + CoachSendButton(canSend, isSending, sendDescription, onSend) + } +} + +@Composable +private fun CoachSendButton( + enabled: Boolean, + isSending: Boolean, + contentDescription: String, + onClick: () -> Unit, +) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .clickable(enabled = enabled, role = Role.Button, onClick = onClick) + .semantics { + role = Role.Button + this.contentDescription = contentDescription + }, + ) { + Surface( + color = if (enabled) MusFitTheme.colors.accent else MusFitTheme.colors.surfaceVariant, + shape = CircleShape, + modifier = Modifier.size(40.dp), ) { - Surface( - color = if (canSend) MusFitTheme.colors.accent else MusFitTheme.colors.surfaceVariant, - shape = CircleShape, - modifier = Modifier.size(40.dp), - ) { - Box(contentAlignment = Alignment.Center) { - if (isSending) { - CircularProgressIndicator( - modifier = Modifier.size(22.dp), - strokeWidth = 2.dp, - color = MusFitTheme.colors.onSurfaceVariant, - ) - } else { - Icon( - Icons.AutoMirrored.Outlined.Send, - contentDescription = null, - tint = if (canSend) MusFitTheme.colors.onAccent else MusFitTheme.colors.onSurfaceVariant, - modifier = Modifier.size(22.dp), - ) - } + Box(contentAlignment = Alignment.Center) { + if (isSending) { + CircularProgressIndicator( + modifier = Modifier.size(22.dp), + strokeWidth = 2.dp, + color = MusFitTheme.colors.onSurfaceVariant, + ) + } else { + Icon( + Icons.AutoMirrored.Outlined.Send, + contentDescription = null, + tint = if (enabled) MusFitTheme.colors.onAccent else MusFitTheme.colors.onSurfaceVariant, + modifier = Modifier.size(22.dp), + ) } } } @@ -548,8 +568,8 @@ private fun CoachChatMessages( ) { if (messages.isEmpty()) { CoachChatEmptyState( - title = "Coach is ready", - body = "Ask about today's food, hydration, training, or recovery.", + title = stringResource(R.string.today_coach_ready), + body = stringResource(R.string.today_coach_ready_body), actionLabel = null, onAction = {}, modifier = modifier, @@ -567,7 +587,7 @@ private fun CoachChatMessages( item { Surface(color = MusFitTheme.colors.surfaceVariant, shape = MusFitTheme.shapes.medium) { Text( - text = "Thinking...", + text = stringResource(R.string.today_thinking), style = MusFitTheme.typography.bodySmall, color = MusFitTheme.colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), @@ -599,7 +619,7 @@ private fun CoachChatBubble(message: AiCoachChatMessage) { Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp)) { Text( text = when { - message.status == AiCoachChatMessageStatus.Sending -> "Thinking..." + message.status == AiCoachChatMessageStatus.Sending -> stringResource(R.string.today_thinking) else -> message.content }, style = MusFitTheme.typography.bodyMedium, diff --git a/feature/today/src/main/java/com/musfit/ui/today/TodayActivityMetricPresentation.kt b/feature/today/src/main/java/com/musfit/ui/today/TodayActivityMetricPresentation.kt new file mode 100644 index 00000000..1e83b875 --- /dev/null +++ b/feature/today/src/main/java/com/musfit/ui/today/TodayActivityMetricPresentation.kt @@ -0,0 +1,69 @@ +package com.musfit.ui.today + +import com.musfit.domain.today.MetricSnapshot +import com.musfit.domain.today.TodayMetric +import com.musfit.feature.today.R +import com.musfit.ui.text.pluralUiText +import com.musfit.ui.text.uiText +import java.util.Locale +import kotlin.math.roundToInt + +internal fun resolveActivityMetric( + metric: TodayMetric, + snapshot: MetricSnapshot, + locale: Locale, +): MetricValueUiState = when (metric) { + TodayMetric.Sessions -> sessionsMetric(snapshot, locale) + TodayMetric.Sleep -> sleepMetric(snapshot, locale) + TodayMetric.Exercise -> exerciseMetric(snapshot, locale) + TodayMetric.ActiveCalories -> activeCaloriesMetric(snapshot, locale) + TodayMetric.RestingHeartRate -> restingHeartRateMetric(snapshot, locale) + TodayMetric.CalorieBalance -> calorieBalanceMetric(snapshot, locale) + TodayMetric.LoggingStreak -> loggingStreakMetric(snapshot, locale) + else -> error("Non-activity metric: ${metric.id}") +} + +private fun sessionsMetric(snapshot: MetricSnapshot, locale: Locale): MetricValueUiState = if (snapshot.sessionTarget > 0) { + MetricValueUiState.WithGoal( + figure = uiText( + R.string.today_sessions_fraction, + textArgument(snapshot.sessionsDone.toLong(), locale), + textArgument(snapshot.sessionTarget.toLong(), locale), + ), + caption = uiText(R.string.today_this_week), + progress = ratio(snapshot.sessionsDone.toDouble(), snapshot.sessionTarget.toDouble()), + ) +} else { + MetricValueUiState.Plain(integerText(snapshot.sessionsDone.toLong(), locale), uiText(R.string.today_this_week)) +} + +private fun exerciseMetric(snapshot: MetricSnapshot, locale: Locale): MetricValueUiState = snapshot.exerciseMinutes?.let { minutes -> + val caption = snapshot.exerciseSessionCount?.let { count -> + pluralUiText(R.plurals.today_sessions, count, textArgument(count.toLong(), locale)) + } ?: uiText(R.string.today_exercise) + MetricValueUiState.Plain(durationText(minutes, locale), caption) +} ?: MetricValueUiState.NoData(uiText(R.string.today_not_connected)) + +private fun sleepMetric(snapshot: MetricSnapshot, locale: Locale): MetricValueUiState = snapshot.sleepMinutes?.let { + MetricValueUiState.Plain(durationText(it, locale), uiText(R.string.today_sleep)) +} ?: MetricValueUiState.NoData(uiText(R.string.today_not_connected)) + +private fun activeCaloriesMetric(snapshot: MetricSnapshot, locale: Locale): MetricValueUiState = snapshot.activeCaloriesKcal?.let { + MetricValueUiState.Plain(integerText(it.roundToInt().toLong(), locale), uiText(R.string.today_active_kcal)) +} ?: MetricValueUiState.NoData(uiText(R.string.today_not_connected)) + +private fun restingHeartRateMetric(snapshot: MetricSnapshot, locale: Locale): MetricValueUiState = snapshot.restingHeartRateBpm?.let { + MetricValueUiState.Plain(uiText(R.string.today_value_bpm, textArgument(it, locale)), uiText(R.string.today_resting)) +} ?: MetricValueUiState.NoData(uiText(R.string.today_not_connected)) + +private fun calorieBalanceMetric(snapshot: MetricSnapshot, locale: Locale): MetricValueUiState = snapshot.activeCaloriesKcal?.let { + MetricValueUiState.Plain( + integerText((snapshot.caloriesKcal - it).roundToInt().toLong(), locale), + uiText(R.string.today_kcal_in_out), + ) +} ?: MetricValueUiState.NoData(uiText(R.string.today_not_connected)) + +private fun loggingStreakMetric(snapshot: MetricSnapshot, locale: Locale) = MetricValueUiState.Plain( + uiText(R.string.today_value_days_short, textArgument(snapshot.loggingStreakDays.toLong(), locale)), + uiText(R.string.today_logging_streak), +) diff --git a/feature/today/src/main/java/com/musfit/ui/today/TodayBodyMetricPresentation.kt b/feature/today/src/main/java/com/musfit/ui/today/TodayBodyMetricPresentation.kt new file mode 100644 index 00000000..b4e96f69 --- /dev/null +++ b/feature/today/src/main/java/com/musfit/ui/today/TodayBodyMetricPresentation.kt @@ -0,0 +1,60 @@ +package com.musfit.ui.today + +import com.musfit.domain.today.MetricSnapshot +import com.musfit.domain.today.TodayMetric +import com.musfit.feature.today.R +import com.musfit.ui.text.UiText +import com.musfit.ui.text.uiText +import java.util.Locale +import kotlin.math.abs + +internal fun resolveBodyMetric( + metric: TodayMetric, + snapshot: MetricSnapshot, + locale: Locale, +): MetricValueUiState = when (metric) { + TodayMetric.Steps -> stepsMetric(snapshot, locale) + TodayMetric.Weight -> weightMetric(snapshot, locale) + TodayMetric.BodyFat -> bodyFatMetric(snapshot, locale) + else -> error("Non-body metric: ${metric.id}") +} + +private fun stepsMetric(snapshot: MetricSnapshot, locale: Locale): MetricValueUiState = snapshot.steps?.let { steps -> + if (snapshot.stepGoal > 0L) { + metricWithGoal( + figure = integerText(steps, locale), + caption = uiText( + R.string.today_of_value_percent, + UiText.Argument.Nested(integerText(snapshot.stepGoal, locale)), + textArgument(percent(steps.toDouble(), snapshot.stepGoal.toDouble()).toLong(), locale), + ), + value = steps.toDouble(), + goal = snapshot.stepGoal.toDouble(), + ) + } else { + MetricValueUiState.Plain(integerText(steps, locale), uiText(R.string.today_steps)) + } +} ?: MetricValueUiState.NoData(uiText(R.string.today_not_connected)) + +private fun weightMetric(snapshot: MetricSnapshot, locale: Locale): MetricValueUiState = snapshot.latestWeightKg?.let { weight -> + val figure = uiText(R.string.today_value_kg, textArgument(weight, locale)) + val caption = snapshot.weightDeltaKg?.let { delta -> + uiText( + R.string.today_weight_delta_7_days, + UiText.Argument.Text(if (delta < 0) "\u2212" else "+"), + textArgument(abs(delta), locale), + ) + } ?: uiText(R.string.today_latest) + MetricValueUiState.Plain(figure, caption) +} ?: MetricValueUiState.NoData(uiText(R.string.today_no_data)) + +private fun bodyFatMetric(snapshot: MetricSnapshot, locale: Locale): MetricValueUiState = snapshot.bodyFatPercent?.let { bodyFat -> + val caption = snapshot.bodyFatDelta?.let { delta -> + uiText( + R.string.today_body_fat_delta, + UiText.Argument.Text(if (delta < 0) "\u2212" else "+"), + textArgument(abs(delta), locale), + ) + } ?: uiText(R.string.today_latest) + MetricValueUiState.Plain(uiText(R.string.today_value_percent, textArgument(bodyFat, locale)), caption) +} ?: MetricValueUiState.NoData(uiText(R.string.today_no_data)) diff --git a/feature/today/src/main/java/com/musfit/ui/today/TodayMetricFormatting.kt b/feature/today/src/main/java/com/musfit/ui/today/TodayMetricFormatting.kt new file mode 100644 index 00000000..8835950c --- /dev/null +++ b/feature/today/src/main/java/com/musfit/ui/today/TodayMetricFormatting.kt @@ -0,0 +1,44 @@ +package com.musfit.ui.today + +import com.musfit.feature.today.R +import com.musfit.ui.text.LocalizedFormatter +import com.musfit.ui.text.UiText +import com.musfit.ui.text.uiText +import java.util.Locale +import kotlin.math.roundToInt + +internal fun metricWithGoal(figure: UiText, caption: UiText, value: Double, goal: Double) = MetricValueUiState.WithGoal(figure, caption, ratio(value, goal)) + +internal fun integerText(value: Long, locale: Locale): UiText = UiText.Verbatim(LocalizedFormatter.integer(value, locale = locale)) + +internal fun textArgument(value: Long, locale: Locale) = UiText.Argument.Text(LocalizedFormatter.integer(value, locale = locale)) + +internal fun textArgument(value: Double, locale: Locale) = UiText.Argument.Text(LocalizedFormatter.number(value, maximumFractionDigits = 1, locale = locale)) + +internal fun litersText(milliliters: Double, locale: Locale): UiText = uiText(R.string.today_value_liters, textArgument(milliliters / 1000.0, locale)) + +internal fun durationText(minutes: Long, locale: Locale): UiText { + val safe = minutes.coerceAtLeast(0L) + val hours = safe / 60L + val remainder = safe % 60L + return if (hours > 0L) { + uiText( + R.string.today_duration_hours_minutes, + textArgument(hours, locale), + UiText.Argument.Text( + LocalizedFormatter.integer( + remainder, + grouping = false, + locale = locale, + minimumIntegerDigits = 2, + ), + ), + ) + } else { + uiText(R.string.today_duration_minutes, textArgument(remainder, locale)) + } +} + +internal fun percent(value: Double, goal: Double): Int = (value / goal * 100.0).roundToInt() + +internal fun ratio(value: Double, goal: Double): Float = if (goal <= 0.0) 0f else (value / goal).coerceIn(0.0, 1.0).toFloat() diff --git a/feature/today/src/main/java/com/musfit/ui/today/TodayMetricPresentation.kt b/feature/today/src/main/java/com/musfit/ui/today/TodayMetricPresentation.kt new file mode 100644 index 00000000..3200e0aa --- /dev/null +++ b/feature/today/src/main/java/com/musfit/ui/today/TodayMetricPresentation.kt @@ -0,0 +1,54 @@ +package com.musfit.ui.today + +import com.musfit.domain.today.MetricSnapshot +import com.musfit.domain.today.TodayMetric +import com.musfit.feature.today.R +import com.musfit.ui.text.UiText +import com.musfit.ui.text.uiText +import java.util.Locale + +sealed interface MetricValueUiState { + data class WithGoal(val figure: UiText, val caption: UiText, val progress: Float) : MetricValueUiState + data class Plain(val figure: UiText, val caption: UiText) : MetricValueUiState + data class NoData(val caption: UiText) : MetricValueUiState +} + +private val metricLabelResources = mapOf( + TodayMetric.Calories to R.string.today_metric_eaten, + TodayMetric.Protein to R.string.today_metric_protein, + TodayMetric.Carbs to R.string.today_metric_carbs, + TodayMetric.Fat to R.string.today_metric_fat, + TodayMetric.Water to R.string.today_metric_water, + TodayMetric.Steps to R.string.today_metric_steps, + TodayMetric.Weight to R.string.today_metric_weight, + TodayMetric.BodyFat to R.string.today_metric_body_fat, + TodayMetric.Sessions to R.string.today_metric_sessions, + TodayMetric.Sleep to R.string.today_metric_sleep, + TodayMetric.Exercise to R.string.today_metric_exercise, + TodayMetric.ActiveCalories to R.string.today_metric_active_calories, + TodayMetric.RestingHeartRate to R.string.today_metric_resting_heart_rate, + TodayMetric.CalorieBalance to R.string.today_metric_balance, + TodayMetric.LoggingStreak to R.string.today_metric_logging_streak, +) + +private val nutritionMetrics = setOf( + TodayMetric.Calories, + TodayMetric.Protein, + TodayMetric.Carbs, + TodayMetric.Fat, + TodayMetric.Water, +) + +private val bodyMetrics = setOf(TodayMetric.Steps, TodayMetric.Weight, TodayMetric.BodyFat) + +internal fun TodayMetric.presentationLabel(): UiText = uiText(metricLabelResources.getValue(this)) + +internal fun resolveMetricPresentation( + metric: TodayMetric, + snapshot: MetricSnapshot, + locale: Locale = Locale.getDefault(), +): MetricValueUiState = when (metric) { + in nutritionMetrics -> resolveNutritionMetric(metric, snapshot, locale) + in bodyMetrics -> resolveBodyMetric(metric, snapshot, locale) + else -> resolveActivityMetric(metric, snapshot, locale) +} diff --git a/feature/today/src/main/java/com/musfit/ui/today/TodayNutritionMetricPresentation.kt b/feature/today/src/main/java/com/musfit/ui/today/TodayNutritionMetricPresentation.kt new file mode 100644 index 00000000..431cfcdb --- /dev/null +++ b/feature/today/src/main/java/com/musfit/ui/today/TodayNutritionMetricPresentation.kt @@ -0,0 +1,73 @@ +package com.musfit.ui.today + +import com.musfit.domain.today.MetricSnapshot +import com.musfit.domain.today.TodayMetric +import com.musfit.feature.today.R +import com.musfit.ui.text.UiText +import com.musfit.ui.text.uiText +import java.util.Locale +import kotlin.math.roundToInt + +internal fun resolveNutritionMetric( + metric: TodayMetric, + snapshot: MetricSnapshot, + locale: Locale, +): MetricValueUiState = when (metric) { + TodayMetric.Calories -> if (snapshot.calorieGoalKcal > 0.0) { + metricWithGoal( + figure = integerText(snapshot.caloriesKcal.roundToInt().toLong(), locale), + caption = uiText( + R.string.today_of_kcal_percent, + textArgument(snapshot.calorieGoalKcal.roundToInt().toLong(), locale), + textArgument(percent(snapshot.caloriesKcal, snapshot.calorieGoalKcal).toLong(), locale), + ), + value = snapshot.caloriesKcal, + goal = snapshot.calorieGoalKcal, + ) + } else { + MetricValueUiState.Plain(integerText(snapshot.caloriesKcal.roundToInt().toLong(), locale), uiText(R.string.today_kcal_eaten)) + } + + TodayMetric.Protein -> gramMetric(snapshot.proteinGrams, snapshot.proteinGoalGrams, locale) + + TodayMetric.Carbs -> gramMetric(snapshot.carbsGrams, snapshot.carbsGoalGrams, locale) + + TodayMetric.Fat -> gramMetric(snapshot.fatGrams, snapshot.fatGoalGrams, locale) + + TodayMetric.Water -> waterMetric(snapshot, locale) + + else -> error("Non-nutrition metric: ${metric.id}") +} + +private fun gramMetric(value: Double, goal: Double, locale: Locale): MetricValueUiState { + val figure = uiText(R.string.today_value_grams, textArgument(value.roundToInt().toLong(), locale)) + return if (goal > 0.0) { + metricWithGoal( + figure = figure, + caption = uiText( + R.string.today_of_grams_percent, + textArgument(goal.roundToInt().toLong(), locale), + textArgument(percent(value, goal).toLong(), locale), + ), + value = value, + goal = goal, + ) + } else { + MetricValueUiState.Plain(figure, uiText(R.string.today_today)) + } +} + +private fun waterMetric(snapshot: MetricSnapshot, locale: Locale): MetricValueUiState = if (snapshot.waterGoalMl > 0.0) { + metricWithGoal( + figure = litersText(snapshot.waterMl, locale), + caption = uiText( + R.string.today_of_value_percent, + UiText.Argument.Nested(litersText(snapshot.waterGoalMl, locale)), + textArgument(percent(snapshot.waterMl, snapshot.waterGoalMl).toLong(), locale), + ), + value = snapshot.waterMl, + goal = snapshot.waterGoalMl, + ) +} else { + MetricValueUiState.Plain(litersText(snapshot.waterMl, locale), uiText(R.string.today_today)) +} diff --git a/feature/today/src/main/java/com/musfit/ui/today/TodayScreen.kt b/feature/today/src/main/java/com/musfit/ui/today/TodayScreen.kt index bcf6715c..b5839054 100644 --- a/feature/today/src/main/java/com/musfit/ui/today/TodayScreen.kt +++ b/feature/today/src/main/java/com/musfit/ui/today/TodayScreen.kt @@ -37,6 +37,8 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics @@ -48,13 +50,19 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.musfit.domain.coach.CoachAction +import com.musfit.feature.today.R import com.musfit.ui.components.EmptyState import com.musfit.ui.components.MusFitScreenScaffold import com.musfit.ui.components.TonalHeaderIconButton +import com.musfit.ui.text.LocalizedFormatter +import com.musfit.ui.text.asString import com.musfit.ui.theme.MusFitTheme import com.musfit.ui.theme.TabAccent import com.musfit.ui.theme.TabAccentRole import com.musfit.ui.theme.tabAccentFor +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.Locale internal enum class TodayNavigationTarget { Food, Training, Profile } @@ -92,6 +100,7 @@ fun TodayScreen( val todayAccent = tabAccentFor(TabAccentRole.Today) val healthAccent = tabAccentFor(TabAccentRole.Profile) val pullRefreshState = rememberPullToRefreshState() + val locale = LocalConfiguration.current.locales[0] Box( modifier = Modifier @@ -103,8 +112,8 @@ fun TodayScreen( ), ) { MusFitScreenScaffold( - title = "Today", - subtitle = todayHeaderDate(java.time.LocalDate.now()), + title = stringResource(R.string.today_title), + subtitle = todayHeaderDate(state.date, locale), actions = { state.readiness?.let { readiness -> ReadinessHeaderChip( @@ -116,7 +125,7 @@ fun TodayScreen( } TonalHeaderIconButton( icon = Icons.Outlined.Edit, - contentDescription = "Edit dashboard", + contentDescription = stringResource(R.string.today_edit_dashboard), onClick = viewModel::openDashboardEditor, ) }, @@ -130,10 +139,10 @@ fun TodayScreen( if (state.feed.isEmpty()) { EmptyState( icon = Icons.Outlined.ChatBubbleOutline, - title = "Let's get started", - body = "Log your first meal and I'll take it from there.", + title = stringResource(R.string.today_get_started), + body = stringResource(R.string.today_get_started_body), accent = todayAccent, - actionLabel = "Log a meal", + actionLabel = stringResource(R.string.today_log_meal), onAction = onOpenFood, ) } else { @@ -193,14 +202,17 @@ fun TodayScreen( } /** The header's quiet date line under the emphasized "Today" title. */ -internal fun todayHeaderDate(date: java.time.LocalDate): String = date.format(java.time.format.DateTimeFormatter.ofPattern("EEEE, d MMMM")) +internal fun todayHeaderDate( + date: LocalDate, + locale: Locale = Locale.getDefault(), +): String = date.format(DateTimeFormatter.ofPattern("EEEE, d MMMM", locale)) /** "Coach" section header with the 7dp coral unread dot from the mock. */ @Composable private fun CoachSectionHeader(hasUnread: Boolean) { Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = "Coach", + text = stringResource(R.string.today_coach), style = MusFitTheme.typography.titleMedium, color = MusFitTheme.colors.onSurface, ) @@ -223,6 +235,10 @@ internal fun ReadinessHeaderChip( onClick: () -> Unit, accent: TabAccent, ) { + val locale = LocalConfiguration.current.locales[0] + val levelLabel = readiness.levelLabel.asString() + val scoreLabel = LocalizedFormatter.integer(readiness.score.toLong(), locale = locale) + val readinessDescription = stringResource(R.string.today_readiness_estimate, scoreLabel, levelLabel) Box( contentAlignment = Alignment.Center, modifier = Modifier @@ -230,7 +246,7 @@ internal fun ReadinessHeaderChip( .clip(CircleShape) .clickable(role = Role.Button, onClick = onClick) .semantics { - contentDescription = "Readiness estimate ${readiness.score}, ${readiness.levelLabel}" + contentDescription = readinessDescription }, ) { Surface( @@ -248,7 +264,7 @@ internal fun ReadinessHeaderChip( tint = accent.onContainer, ) Text( - text = readiness.label, + text = readiness.label.asString(), style = MusFitTheme.typography.labelMedium, fontWeight = FontWeight.Medium, color = accent.onContainer, diff --git a/feature/today/src/main/java/com/musfit/ui/today/TodayViewModel.kt b/feature/today/src/main/java/com/musfit/ui/today/TodayViewModel.kt index d6a04da9..97db7224 100644 --- a/feature/today/src/main/java/com/musfit/ui/today/TodayViewModel.kt +++ b/feature/today/src/main/java/com/musfit/ui/today/TodayViewModel.kt @@ -23,14 +23,17 @@ import com.musfit.domain.coach.TimeOfDay import com.musfit.domain.model.NutritionTotals import com.musfit.domain.today.DailyReadinessSample import com.musfit.domain.today.LoggingStreakCalculator -import com.musfit.domain.today.MetricResolver import com.musfit.domain.today.MetricSnapshot -import com.musfit.domain.today.MetricValue import com.musfit.domain.today.ReadinessCalculator +import com.musfit.domain.today.ReadinessLevel import com.musfit.domain.today.TodayMetric import com.musfit.domain.today.WeeklyGoalsCalculator import com.musfit.domain.today.countSessionsInWeek import com.musfit.domain.today.vitalsTileMetrics +import com.musfit.feature.today.R +import com.musfit.ui.text.LocalizedFormatter +import com.musfit.ui.text.UiText +import com.musfit.ui.text.uiText import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job @@ -55,24 +58,27 @@ import java.util.Locale import javax.inject.Inject data class CoachFeedDayGroup( - val label: String, + val label: UiText, val messages: List, ) data class MetricCardUiState( val metric: TodayMetric, - val label: String, - val value: MetricValue, + val label: UiText, + val value: MetricValueUiState, ) data class TodayReadinessUiState( val score: Int, - val levelLabel: String, - val label: String = "Ready $score", + val levelLabel: UiText, + val label: UiText = uiText( + R.string.today_ready_score, + UiText.Argument.Text(LocalizedFormatter.integer(score.toLong())), + ), ) data class TodayUiState( - val dateLabel: String = "", + val date: LocalDate = LocalDate.now(), val vitals: List = emptyList(), val readiness: TodayReadinessUiState? = null, val isRefreshing: Boolean = false, @@ -96,7 +102,7 @@ class TodayViewModel internal constructor( ) : ViewModel() { private val initialDate = dateProvider() private val mutableState = MutableStateFlow( - TodayUiState(dateLabel = initialDate.format(currentDateFormatter())), + TodayUiState(date = initialDate), ) private var currentUserGoals = UserGoals() @@ -115,7 +121,7 @@ class TodayViewModel internal constructor( currentPins = dashboard.pins currentUserGoals = dashboard.userGoals TodayRepositoryUiState( - dateLabel = date.format(currentDateFormatter()), + date = date, vitals = dashboard.vitals, readiness = dashboard.readiness, feed = buildFeedGroups(messages, date), @@ -128,7 +134,7 @@ class TodayViewModel internal constructor( repositoryObservation, ) { base, observed -> base.copy( - dateLabel = observed.dateLabel, + date = observed.date, vitals = observed.vitals, readiness = observed.readiness, feed = observed.feed, @@ -479,9 +485,9 @@ internal fun buildFeedGroups( CoachFeedDayGroup( label = when (day) { - today -> "Today" - today.minusDays(1) -> "Yesterday" - else -> day.format(formatter) + today -> uiText(R.string.today_day_today) + today.minusDays(1) -> uiText(R.string.today_day_yesterday) + else -> UiText.Verbatim(day.format(formatter)) }, messages = dayMessages.sortedByDescending { it.firstSeenAtEpochMillis }, ) @@ -510,7 +516,7 @@ private data class TodayDashboardUiState( ) private data class TodayRepositoryUiState( - val dateLabel: String, + val date: LocalDate, val vitals: List, val readiness: TodayReadinessUiState?, val feed: List, @@ -568,7 +574,7 @@ private fun buildVitals( loggingStreakDays = LoggingStreakCalculator.streakDays(food.loggedDays, date.toEpochDay()), ) return vitalsTileMetrics(pins).map { metric -> - MetricCardUiState(metric, metric.label, MetricResolver.resolve(metric, snapshot)) + MetricCardUiState(metric, metric.presentationLabel(), resolveMetricPresentation(metric, snapshot)) } } @@ -589,7 +595,13 @@ private fun buildReadinessUiState( ) ?: return null return TodayReadinessUiState( score = readiness.score, - levelLabel = readiness.level.label, + levelLabel = uiText( + when (readiness.level) { + ReadinessLevel.Low -> R.string.today_readiness_low + ReadinessLevel.Moderate -> R.string.today_readiness_moderate + ReadinessLevel.High -> R.string.today_readiness_high + }, + ), ) } diff --git a/feature/today/src/main/java/com/musfit/ui/today/TodayVitalsUi.kt b/feature/today/src/main/java/com/musfit/ui/today/TodayVitalsUi.kt index 6123ebb5..bc12fcb3 100644 --- a/feature/today/src/main/java/com/musfit/ui/today/TodayVitalsUi.kt +++ b/feature/today/src/main/java/com/musfit/ui/today/TodayVitalsUi.kt @@ -2,6 +2,7 @@ package com.musfit.ui.today import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row @@ -34,6 +35,7 @@ import androidx.compose.material.icons.filled.WaterDrop import androidx.compose.material.icons.filled.Whatshot import androidx.compose.material.icons.outlined.ArrowDownward import androidx.compose.material.icons.outlined.ArrowUpward +import androidx.compose.material3.BottomSheetDefaults import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Checkbox @@ -46,10 +48,14 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.role @@ -58,10 +64,11 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.musfit.domain.today.MetricValue import com.musfit.domain.today.TodayMetric +import com.musfit.feature.today.R import com.musfit.ui.components.WavyProgressBar import com.musfit.ui.components.gridGroupShape +import com.musfit.ui.text.asString import com.musfit.ui.theme.AmberBright import com.musfit.ui.theme.AmberContainerDark import com.musfit.ui.theme.AmberInkDark @@ -147,16 +154,17 @@ private fun VitalsTile( ) { val family = vitalsFamilyFor(card.metric) val figure = when (val value = card.value) { - is MetricValue.WithGoal -> value.figure - is MetricValue.Plain -> value.figure - is MetricValue.NoData -> "—" + is MetricValueUiState.WithGoal -> value.figure.asString() + is MetricValueUiState.Plain -> value.figure.asString() + is MetricValueUiState.NoData -> "—" } val caption = when (val value = card.value) { - is MetricValue.WithGoal -> value.caption - is MetricValue.Plain -> value.caption - is MetricValue.NoData -> value.caption + is MetricValueUiState.WithGoal -> value.caption.asString() + is MetricValueUiState.Plain -> value.caption.asString() + is MetricValueUiState.NoData -> value.caption.asString() } - val label = vitalsTileLabel(card.metric) + val locale = LocalConfiguration.current.locales[0] + val label = card.label.asString() Surface( onClick = onClick, color = family.container, @@ -178,7 +186,7 @@ private fun VitalsTile( modifier = Modifier.size(15.dp), ) Text( - text = label.uppercase(), + text = label.uppercase(locale), style = MusFitTheme.typography.labelMedium.copy(fontSize = 11.5.sp, letterSpacing = 0.4.sp), fontWeight = FontWeight.ExtraBold, color = family.onContainer, @@ -200,7 +208,7 @@ private fun VitalsTile( color = family.onContainer, maxLines = 1, ) - (card.value as? MetricValue.WithGoal)?.let { value -> + (card.value as? MetricValueUiState.WithGoal)?.let { value -> Spacer(Modifier.height(8.dp)) WavyProgressBar( progress = value.progress, @@ -280,12 +288,6 @@ internal fun vitalsFamilyFor(metric: TodayMetric): VitalsTileFamily { } } -/** Tile overline: the mock says EATEN, not CALORIES; everything else keeps its pool label. */ -internal fun vitalsTileLabel(metric: TodayMetric): String = when (metric) { - TodayMetric.Calories -> "Eaten" - else -> metric.label -} - /** Tile overline glyphs from the 8a mock (restaurant / steps / exercise / water_drop). */ internal fun vitalsTileIcon(metric: TodayMetric): ImageVector = when (metric) { TodayMetric.Calories -> Icons.Filled.Restaurant @@ -340,120 +342,143 @@ fun DashboardEditSheet( onDismiss: () -> Unit, ) { val accent = tabAccentFor(TabAccentRole.Today) - ModalBottomSheet(onDismissRequest = onDismiss, containerColor = MusFitTheme.colors.surface) { - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(start = 20.dp, end = 20.dp, bottom = 28.dp), - verticalArrangement = Arrangement.spacedBy(MusFitTheme.spacing.sm), - ) { - Text( - text = "Dashboard", - style = MusFitTheme.typography.titleLarge, - color = MusFitTheme.colors.onSurface, - ) - Text( - text = "Pin the metrics you're focused on. Each pin is one tile on the grid.", - style = MusFitTheme.typography.bodyMedium, - color = MusFitTheme.colors.onSurfaceVariant, - ) + val layoutDirection = LocalLayoutDirection.current + ModalBottomSheet( + onDismissRequest = onDismiss, + containerColor = MusFitTheme.colors.surface, + dragHandle = { + Box( + modifier = Modifier.size(48.dp), + contentAlignment = Alignment.Center, + ) { + BottomSheetDefaults.DragHandle() + } + }, + ) { + CompositionLocalProvider(LocalLayoutDirection provides layoutDirection) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(start = 20.dp, end = 20.dp, bottom = 28.dp), + verticalArrangement = Arrangement.spacedBy(MusFitTheme.spacing.sm), + ) { + Text( + text = stringResource(R.string.today_dashboard), + style = MusFitTheme.typography.titleLarge, + color = MusFitTheme.colors.onSurface, + ) + Text( + text = stringResource(R.string.today_dashboard_explanation), + style = MusFitTheme.typography.bodyMedium, + color = MusFitTheme.colors.onSurfaceVariant, + ) - // Pinned first (in order, with reorder controls), then the rest of the pool. - val pinned = state.editPins - pinned.forEachIndexed { index, metric -> - // The last remaining pin cannot be unpinned — surface the rule as disabled. - val toggleEnabled = pinned.size > 1 - Row( - modifier = Modifier - .fillMaxWidth() - .toggleable( - value = true, - enabled = toggleEnabled, - role = Role.Checkbox, - onValueChange = { onTogglePin(metric) }, - ), - verticalAlignment = Alignment.CenterVertically, - ) { - Checkbox(checked = true, onCheckedChange = null, enabled = toggleEnabled) - Text( - text = metric.label, - style = MusFitTheme.typography.bodyMedium, - color = MusFitTheme.colors.onSurface, - modifier = Modifier.weight(1f), - ) - val arrowColors = IconButtonDefaults.iconButtonColors(contentColor = MusFitTheme.colors.onSurfaceVariant) - IconButton( - onClick = { onMovePin(metric, true) }, - enabled = index > 0, - colors = arrowColors, - modifier = Modifier.size(48.dp).semantics { role = Role.Button }, + // Pinned first (in order, with reorder controls), then the rest of the pool. + val pinned = state.editPins + pinned.forEachIndexed { index, metric -> + val metricLabel = metric.presentationLabel().asString() + // The last remaining pin cannot be unpinned — surface the rule as disabled. + val toggleEnabled = pinned.size > 1 + Row( + modifier = Modifier + .fillMaxWidth() + .toggleable( + value = true, + enabled = toggleEnabled, + role = Role.Checkbox, + onValueChange = { onTogglePin(metric) }, + ), + verticalAlignment = Alignment.CenterVertically, ) { - Icon(Icons.Outlined.ArrowUpward, contentDescription = "Move ${metric.label} up") + Checkbox(checked = true, onCheckedChange = null, enabled = toggleEnabled) + Text( + text = metricLabel, + style = MusFitTheme.typography.bodyMedium, + color = MusFitTheme.colors.onSurface, + modifier = Modifier.weight(1f), + ) + val arrowColors = + IconButtonDefaults.iconButtonColors(contentColor = MusFitTheme.colors.onSurfaceVariant) + IconButton( + onClick = { onMovePin(metric, true) }, + enabled = index > 0, + colors = arrowColors, + modifier = Modifier.size(48.dp).semantics { role = Role.Button }, + ) { + Icon( + Icons.Outlined.ArrowUpward, + contentDescription = stringResource(R.string.today_move_metric_up, metricLabel), + ) + } + IconButton( + onClick = { onMovePin(metric, false) }, + enabled = index < pinned.lastIndex, + colors = arrowColors, + modifier = Modifier.size(48.dp).semantics { role = Role.Button }, + ) { + Icon( + Icons.Outlined.ArrowDownward, + contentDescription = stringResource(R.string.today_move_metric_down, metricLabel), + ) + } } - IconButton( - onClick = { onMovePin(metric, false) }, - enabled = index < pinned.lastIndex, - colors = arrowColors, - modifier = Modifier.size(48.dp).semantics { role = Role.Button }, + } + TodayMetric.entries.filter { it !in pinned }.forEach { metric -> + val metricLabel = metric.presentationLabel().asString() + Row( + modifier = Modifier + .fillMaxWidth() + .toggleable( + value = false, + role = Role.Checkbox, + onValueChange = { onTogglePin(metric) }, + ), + verticalAlignment = Alignment.CenterVertically, ) { - Icon(Icons.Outlined.ArrowDownward, contentDescription = "Move ${metric.label} down") + Checkbox(checked = false, onCheckedChange = null) + Text( + text = metricLabel, + style = MusFitTheme.typography.bodyMedium, + color = MusFitTheme.colors.onSurface, + modifier = Modifier.weight(1f), + ) } } - } - TodayMetric.entries.filter { it !in pinned }.forEach { metric -> - Row( - modifier = Modifier - .fillMaxWidth() - .toggleable( - value = false, - role = Role.Checkbox, - onValueChange = { onTogglePin(metric) }, - ), - verticalAlignment = Alignment.CenterVertically, + + Spacer(Modifier.height(MusFitTheme.spacing.sm)) + Text( + text = stringResource(R.string.today_goals), + style = MusFitTheme.typography.titleMedium, + color = MusFitTheme.colors.onSurface, + ) + OutlinedTextField( + value = state.stepGoalInput, + onValueChange = onStepGoalChanged, + label = { Text(stringResource(R.string.today_daily_step_goal)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = state.sessionTargetInput, + onValueChange = onSessionTargetChanged, + label = { Text(stringResource(R.string.today_workouts_per_week)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + ) + Button( + onClick = onSave, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = accent.color, + contentColor = accent.onColor, + ), ) { - Checkbox(checked = false, onCheckedChange = null) - Text( - text = metric.label, - style = MusFitTheme.typography.bodyMedium, - color = MusFitTheme.colors.onSurface, - modifier = Modifier.weight(1f), - ) + Text(stringResource(R.string.today_save_dashboard)) } } - - Spacer(Modifier.height(MusFitTheme.spacing.sm)) - Text( - text = "Goals", - style = MusFitTheme.typography.titleMedium, - color = MusFitTheme.colors.onSurface, - ) - OutlinedTextField( - value = state.stepGoalInput, - onValueChange = onStepGoalChanged, - label = { Text("Daily step goal") }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = state.sessionTargetInput, - onValueChange = onSessionTargetChanged, - label = { Text("Workouts per week") }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth(), - ) - Button( - onClick = onSave, - modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.buttonColors( - containerColor = accent.color, - contentColor = accent.onColor, - ), - ) { - Text("Save dashboard") - } } } } diff --git a/feature/today/src/main/res/values/strings.xml b/feature/today/src/main/res/values/strings.xml new file mode 100644 index 00000000..7a04998c --- /dev/null +++ b/feature/today/src/main/res/values/strings.xml @@ -0,0 +1,106 @@ + + + Today + Edit dashboard + Let\'s get started + Log your first meal and I\'ll take it from there. + Log a meal + Coach + Readiness estimate %1$s, %2$s + Ready %1$s + Low + Moderate + High + Today + Yesterday + + Eaten + Protein + Carbs + Fat + Water + Steps + Weight + Body fat + Sessions + Sleep + Exercise + Active kcal + Resting HR + Balance + Streak + of %1$s kcal · %2$s%% + of %1$s · %2$s%% + of %1$s g · %2$s%% + kcal eaten + today + steps + %1$s kg + %1$s%2$s kg · 7d + latest + No data + Not connected + %1$s%% + %1$s%2$s pts + %1$s/%2$s + this week + sleep + exercise + active kcal + %1$s bpm + resting + kcal in − out + %1$s d + logging streak + %1$s g + %1$s L + %1$sh %2$sm + %1$sm + + %1$s session + %1$s sessions + + + Unread + Dismiss message + Open Food + Open Training + Open Profile + Start workout + Plan + Nutrition + Training + Trend + Achievement + Recap + Ask coach + Clear coach chat + Close coach chat + Coach connection is off + Choose Hermes or another endpoint in Profile settings. + Configure + Retry + Ask about today + Coach message + Sending coach message + Send coach message + Coach is ready + Ask about today\'s food, hydration, training, or recovery. + Thinking… + Off + API coach + Hermes + OpenClaw + Local agent + Coach is not reachable. + Could not clear coach chat. + + Dashboard + Pin the metrics you\'re focused on. Each pin is one tile on the grid. + Move %1$s up + Move %1$s down + Goals + Daily step goal + Workouts per week + Save dashboard + diff --git a/feature/today/src/test/java/com/musfit/ui/today/TodayMetricPresentationTest.kt b/feature/today/src/test/java/com/musfit/ui/today/TodayMetricPresentationTest.kt new file mode 100644 index 00000000..33becb81 --- /dev/null +++ b/feature/today/src/test/java/com/musfit/ui/today/TodayMetricPresentationTest.kt @@ -0,0 +1,177 @@ +package com.musfit.ui.today + +import com.musfit.domain.today.MetricSnapshot +import com.musfit.domain.today.TodayMetric +import com.musfit.feature.today.R +import com.musfit.ui.text.UiText +import com.musfit.ui.text.pluralUiText +import com.musfit.ui.text.uiText +import org.junit.Assert.assertEquals +import org.junit.Test +import java.util.Locale + +class TodayMetricPresentationTest { + @Test + fun calories_withGoalUsesLocalizedFullValuesAndUnclampedPercent() { + val value = resolveMetricPresentation(TodayMetric.Calories, snapshot(), Locale.US) + as MetricValueUiState.WithGoal + + assertEquals(UiText.Verbatim("1,200"), value.figure) + assertEquals( + uiText( + R.string.today_of_kcal_percent, + text("2,000"), + text("60"), + ), + value.caption, + ) + assertEquals(0.6f, value.progress, 0.001f) + + val overGoal = resolveMetricPresentation( + TodayMetric.Calories, + snapshot(caloriesKcal = 3_500.0, calorieGoalKcal = 2_000.0), + Locale.US, + ) as MetricValueUiState.WithGoal + assertEquals( + uiText(R.string.today_of_kcal_percent, text("2,000"), text("175")), + overGoal.caption, + ) + assertEquals(1f, overGoal.progress, 0.001f) + } + + @Test + fun germanNumbersUseGroupingAndDecimalSeparators() { + val steps = resolveMetricPresentation( + TodayMetric.Steps, + snapshot(steps = 5_500L, stepGoal = 10_000L), + Locale.GERMANY, + ) as MetricValueUiState.WithGoal + assertEquals(UiText.Verbatim("5.500"), steps.figure) + assertEquals( + uiText(R.string.today_of_value_percent, UiText.Argument.Nested(UiText.Verbatim("10.000")), text("55")), + steps.caption, + ) + + val weight = resolveMetricPresentation(TodayMetric.Weight, snapshot(), Locale.GERMANY) + as MetricValueUiState.Plain + assertEquals(uiText(R.string.today_value_kg, text("82,4")), weight.figure) + assertEquals(uiText(R.string.today_weight_delta_7_days, text("−"), text("0,4")), weight.caption) + } + + @Test + fun missingAndGoalLessMetricsUseTypedResourceCopy() { + assertEquals( + MetricValueUiState.NoData(uiText(R.string.today_not_connected)), + resolveMetricPresentation(TodayMetric.Steps, snapshot(steps = null), Locale.US), + ) + assertEquals( + MetricValueUiState.NoData(uiText(R.string.today_no_data)), + resolveMetricPresentation(TodayMetric.BodyFat, snapshot(bodyFatPercent = null), Locale.US), + ) + assertEquals( + MetricValueUiState.Plain( + uiText(R.string.today_value_grams, text("80")), + uiText(R.string.today_today), + ), + resolveMetricPresentation(TodayMetric.Protein, snapshot(proteinGoalGrams = 0.0), Locale.US), + ) + } + + @Test + fun nutritionAndWaterGoalsKeepUnitsInResources() { + val protein = resolveMetricPresentation(TodayMetric.Protein, snapshot(), Locale.US) + as MetricValueUiState.WithGoal + assertEquals(uiText(R.string.today_value_grams, text("80")), protein.figure) + assertEquals( + uiText(R.string.today_of_grams_percent, text("150"), text("53")), + protein.caption, + ) + + val water = resolveMetricPresentation( + TodayMetric.Water, + snapshot(waterMl = 1_500.0, waterGoalMl = 2_500.0), + Locale.US, + ) as MetricValueUiState.WithGoal + assertEquals(uiText(R.string.today_value_liters, text("1.5")), water.figure) + assertEquals( + uiText( + R.string.today_of_value_percent, + UiText.Argument.Nested(uiText(R.string.today_value_liters, text("2.5"))), + text("60"), + ), + water.caption, + ) + } + + @Test + fun sessionsSleepAndExerciseUseTypedCountsAndPlurals() { + val sessions = resolveMetricPresentation(TodayMetric.Sessions, snapshot(), Locale.US) + as MetricValueUiState.WithGoal + assertEquals(uiText(R.string.today_sessions_fraction, text("2"), text("4")), sessions.figure) + assertEquals(uiText(R.string.today_this_week), sessions.caption) + + val sleep = resolveMetricPresentation(TodayMetric.Sleep, snapshot(), Locale.US) + as MetricValueUiState.Plain + assertEquals(uiText(R.string.today_duration_hours_minutes, text("7"), text("45")), sleep.figure) + assertEquals(uiText(R.string.today_sleep), sleep.caption) + + val exercise = resolveMetricPresentation(TodayMetric.Exercise, snapshot(), Locale.US) + as MetricValueUiState.Plain + assertEquals(uiText(R.string.today_duration_hours_minutes, text("1"), text("08")), exercise.figure) + assertEquals(pluralUiText(R.plurals.today_sessions, 2, text("2")), exercise.caption) + } + + @Test + fun balanceAndRestingHeartRateKeepCalculatedValuesTyped() { + val balance = resolveMetricPresentation(TodayMetric.CalorieBalance, snapshot(), Locale.US) + as MetricValueUiState.Plain + assertEquals(UiText.Verbatim("780"), balance.figure) + assertEquals(uiText(R.string.today_kcal_in_out), balance.caption) + + val heartRate = resolveMetricPresentation(TodayMetric.RestingHeartRate, snapshot(), Locale.US) + as MetricValueUiState.Plain + assertEquals(uiText(R.string.today_value_bpm, text("58")), heartRate.figure) + assertEquals(uiText(R.string.today_resting), heartRate.caption) + } + + private fun snapshot( + caloriesKcal: Double = 1_200.0, + calorieGoalKcal: Double = 2_000.0, + proteinGrams: Double = 80.0, + proteinGoalGrams: Double = 150.0, + waterMl: Double = 1_000.0, + waterGoalMl: Double = 2_000.0, + steps: Long? = 8_200L, + stepGoal: Long = 10_000L, + latestWeightKg: Double? = 82.4, + weightDeltaKg: Double? = -0.4, + bodyFatPercent: Double? = 18.5, + ) = MetricSnapshot( + caloriesKcal = caloriesKcal, + calorieGoalKcal = calorieGoalKcal, + proteinGrams = proteinGrams, + proteinGoalGrams = proteinGoalGrams, + carbsGrams = 130.0, + carbsGoalGrams = 200.0, + fatGrams = 40.0, + fatGoalGrams = 60.0, + waterMl = waterMl, + waterGoalMl = waterGoalMl, + steps = steps, + stepGoal = stepGoal, + latestWeightKg = latestWeightKg, + weightDeltaKg = weightDeltaKg, + bodyFatPercent = bodyFatPercent, + bodyFatDelta = -0.3, + sessionsDone = 2, + sessionTarget = 4, + activeCaloriesKcal = 420.0, + sleepMinutes = 465L, + exerciseMinutes = 68L, + exerciseSessionCount = 2, + restingHeartRateBpm = 58L, + loggingStreakDays = 5, + ) + + private fun text(value: String) = UiText.Argument.Text(value) +} diff --git a/feature/today/src/test/java/com/musfit/ui/today/TodayViewModelTest.kt b/feature/today/src/test/java/com/musfit/ui/today/TodayViewModelTest.kt index f349418a..e86ba2e0 100644 --- a/feature/today/src/test/java/com/musfit/ui/today/TodayViewModelTest.kt +++ b/feature/today/src/test/java/com/musfit/ui/today/TodayViewModelTest.kt @@ -58,9 +58,12 @@ import com.musfit.domain.model.NutritionTotals import com.musfit.domain.profile.ActivityLevel import com.musfit.domain.profile.GoalType import com.musfit.domain.profile.RecommendedTargets -import com.musfit.domain.today.MetricValue import com.musfit.domain.today.TodayMetric +import com.musfit.feature.today.R import com.musfit.testing.MainDispatcherRule +import com.musfit.ui.text.LocalizedFormatter +import com.musfit.ui.text.UiText +import com.musfit.ui.text.uiText import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow @@ -252,7 +255,7 @@ class TodayViewModelTest { assertEquals(2, aiCoachRepository.settingsSubscriptionStarts) assertEquals(2, chatRepository.messageSubscriptionStarts) - assertEquals("Off", viewModel.state.value.providerLabel) + assertEquals(uiText(R.string.today_coach_off), viewModel.state.value.providerLabel) assertEquals(listOf("resumed-message"), viewModel.state.value.messages.map { it.id }) resumed.cancel() @@ -387,7 +390,10 @@ class TodayViewModelTest { dispatcher.scheduler.advanceUntilIdle() val groups = viewModel.state.value.feed - assertEquals(listOf("Today", "Yesterday"), groups.map { it.label }) + assertEquals( + listOf(uiText(R.string.today_day_today), uiText(R.string.today_day_yesterday)), + groups.map { it.label }, + ) assertEquals(listOf(3L, 2L), groups[0].messages.map { it.id }) assertEquals(listOf(1L), groups[1].messages.map { it.id }) } @@ -444,9 +450,9 @@ class TodayViewModelTest { ) assertEquals(listOf(1L, 2L, 3L), groups.map { it.messages.single().id }) - assertEquals("Today", groups[0].label) - assertEquals("Yesterday", groups[1].label) - assertTrue(groups[2].label != "Today" && groups[2].label != "Yesterday") + assertEquals(uiText(R.string.today_day_today), groups[0].label) + assertEquals(uiText(R.string.today_day_yesterday), groups[1].label) + assertTrue(groups[2].label is UiText.Verbatim) } @Test @@ -456,9 +462,9 @@ class TodayViewModelTest { val message = message(1, today.minusDays(3), firstSeenAt = 30L) try { Locale.setDefault(Locale.ENGLISH) - val english = buildFeedGroups(listOf(message), today).single().label + val english = (buildFeedGroups(listOf(message), today).single().label as UiText.Verbatim).value Locale.setDefault(Locale.GERMAN) - val german = buildFeedGroups(listOf(message), today).single().label + val german = (buildFeedGroups(listOf(message), today).single().label as UiText.Verbatim).value assertEquals("Friday, 10 July", english) assertEquals("Freitag, 10 Juli", german) @@ -522,9 +528,16 @@ class TodayViewModelTest { vitals.map { it.metric }, ) // FakeFoodRepository: 600 eaten of 2000 goal → the tile shows EATEN with percent. - val calories = vitals[0].value as MetricValue.WithGoal - assertEquals("600", calories.figure) - assertEquals("of 2,000 kcal · 30%", calories.caption) + val calories = vitals[0].value as MetricValueUiState.WithGoal + assertEquals(UiText.Verbatim("600"), calories.figure) + assertEquals( + uiText( + R.string.today_of_kcal_percent, + UiText.Argument.Text("2,000"), + UiText.Argument.Text("30"), + ), + calories.caption, + ) assertEquals(0.3f, calories.progress, 0.001f) } @@ -564,7 +577,10 @@ class TodayViewModelTest { ?.score, ) assertEquals( - "Ready 86", + uiText( + R.string.today_ready_score, + UiText.Argument.Text(LocalizedFormatter.integer(86L)), + ), viewModel.state.value.readiness ?.label, ) diff --git a/feature/today/src/testInternalDebug/java/com/musfit/ui/today/TodayComposeSemanticsTest.kt b/feature/today/src/testInternalDebug/java/com/musfit/ui/today/TodayComposeSemanticsTest.kt index 041a833b..b2b48e13 100644 --- a/feature/today/src/testInternalDebug/java/com/musfit/ui/today/TodayComposeSemanticsTest.kt +++ b/feature/today/src/testInternalDebug/java/com/musfit/ui/today/TodayComposeSemanticsTest.kt @@ -24,8 +24,8 @@ import androidx.compose.ui.unit.dp import com.musfit.data.repository.CoachMessage import com.musfit.domain.coach.CoachAction import com.musfit.domain.coach.CoachMessageCategory -import com.musfit.domain.today.MetricValue import com.musfit.domain.today.TodayMetric +import com.musfit.ui.text.UiText import com.musfit.ui.theme.MusFitTheme import com.musfit.ui.theme.TabAccentRole import com.musfit.ui.theme.tabAccentFor @@ -127,7 +127,7 @@ class TodayComposeSemanticsTest { compose.setContent { MusFitTheme { ReadinessHeaderChip( - readiness = TodayReadinessUiState(score = 82, levelLabel = "Good"), + readiness = TodayReadinessUiState(score = 82, levelLabel = UiText.Verbatim("Good")), onClick = {}, accent = tabAccentFor(TabAccentRole.Today), ) @@ -149,8 +149,12 @@ class TodayComposeSemanticsTest { vitals = listOf( MetricCardUiState( metric = TodayMetric.Calories, - label = "Calories", - value = MetricValue.WithGoal("10", "of 20 kcal", 0.5f), + label = UiText.Verbatim("Calories"), + value = MetricValueUiState.WithGoal( + figure = UiText.Verbatim("10"), + caption = UiText.Verbatim("of 20 kcal"), + progress = 0.5f, + ), ), ), onMetricClick = opened::add, @@ -158,7 +162,7 @@ class TodayComposeSemanticsTest { } } - val tile = compose.onNodeWithContentDescription("Eaten: 10 of 20 kcal") + val tile = compose.onNodeWithContentDescription("Calories: 10 of 20 kcal") .assert(hasClickAction()) .assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button)) assertMinimumTouchTarget(tile) @@ -211,7 +215,7 @@ class TodayComposeSemanticsTest { } } - val calories = compose.onNodeWithText(TodayMetric.Calories.label) + val calories = compose.onNodeWithText("Eaten") .assert(hasClickAction()) .assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Checkbox)) .assert(SemanticsMatcher.expectValue(SemanticsProperties.ToggleableState, ToggleableState.On)) @@ -219,9 +223,9 @@ class TodayComposeSemanticsTest { calories.performClick() assertEquals(listOf(TodayMetric.Calories), toggled) - val moveUp = compose.onNodeWithContentDescription("Move Calories up").assertIsNotEnabled() + val moveUp = compose.onNodeWithContentDescription("Move Eaten up").assertIsNotEnabled() assertMinimumTouchTarget(moveUp) - val moveDown = compose.onNodeWithContentDescription("Move Calories down") + val moveDown = compose.onNodeWithContentDescription("Move Eaten down") .assert(hasClickAction()) .assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button)) assertMinimumTouchTarget(moveDown) @@ -237,7 +241,7 @@ class TodayComposeSemanticsTest { compose.setContent { MusFitTheme { CoachFeed( - groups = listOf(CoachFeedDayGroup(label = "Today", messages = listOf(message))), + groups = listOf(CoachFeedDayGroup(label = UiText.Verbatim("Today"), messages = listOf(message))), onAction = onAction, onDismiss = onDismiss, ) diff --git a/feature/today/src/testInternalDebug/java/com/musfit/ui/today/TodayScreenshotRegressionTest.kt b/feature/today/src/testInternalDebug/java/com/musfit/ui/today/TodayScreenshotRegressionTest.kt index 794846ef..80d6703a 100644 --- a/feature/today/src/testInternalDebug/java/com/musfit/ui/today/TodayScreenshotRegressionTest.kt +++ b/feature/today/src/testInternalDebug/java/com/musfit/ui/today/TodayScreenshotRegressionTest.kt @@ -18,8 +18,9 @@ import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import com.github.takahirom.roborazzi.captureRoboImage -import com.musfit.domain.today.MetricValue +import com.musfit.domain.today.MetricSnapshot import com.musfit.domain.today.TodayMetric +import com.musfit.ui.text.UiText import com.musfit.ui.theme.MusFitTheme import org.junit.Assert.assertTrue import org.junit.Rule @@ -28,6 +29,7 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode +import java.util.Locale @RunWith(RobolectricTestRunner::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) @@ -45,23 +47,23 @@ class TodayScreenshotRegressionTest { vitals = listOf( MetricCardUiState( TodayMetric.Calories, - "EATEN", - MetricValue.WithGoal("1,443", "of 2,450 kcal · 59%", 0.59f), + UiText.Verbatim("EATEN"), + metricWithGoal("1,443", "of 2,450 kcal · 59%", 0.59f), ), MetricCardUiState( TodayMetric.Steps, - "STEPS", - MetricValue.WithGoal("7,800", "of 10,000 · 78%", 0.78f), + UiText.Verbatim("STEPS"), + metricWithGoal("7,800", "of 10,000 · 78%", 0.78f), ), MetricCardUiState( TodayMetric.Protein, - "PROTEIN", - MetricValue.WithGoal("118 g", "of 180 g · 66%", 0.66f), + UiText.Verbatim("PROTEIN"), + metricWithGoal("118 g", "of 180 g · 66%", 0.66f), ), MetricCardUiState( TodayMetric.Water, - "WATER", - MetricValue.WithGoal("1.8 L", "of 2.6 L · 67%", 0.67f), + UiText.Verbatim("WATER"), + metricWithGoal("1.8 L", "of 2.6 L · 67%", 0.67f), ), ), onMetricClick = {}, @@ -74,6 +76,48 @@ class TodayScreenshotRegressionTest { compose.onRoot().captureRoboImage("today-tablet-dark-ltr.png") } + @Test + @Config(qualifiers = "en-rXA-w400dp-h800dp-mdpi") + fun todayDashboard_phone_pseudo_largeFont() { + // Off-viewport rows have clipped semantics bounds; visible control sizing is covered by TodayComposeSemanticsTest. + capture("today-dashboard-phone-pseudo-font-150.png", fontScale = 1.5f, verifyTouchTargets = false) { + DashboardFixture() + } + } + + @Test + @Config(qualifiers = "ar-rXB-w400dp-h800dp-mdpi") + fun todayDashboard_phone_pseudoRtl() { + capture("today-dashboard-phone-pseudo-rtl.png", rtl = true, verifyTouchTargets = false) { + DashboardFixture() + } + } + + @Test + @Config(qualifiers = "de-rDE-w610dp-h900dp-mdpi") + fun todayVitals_foldable_germanNumbers() { + capture("today-vitals-foldable-german.png") { + Box(Modifier.fillMaxSize().padding(32.dp)) { + TodayVitalsGrid(vitals = localizedVitals(Locale.GERMANY), onMetricClick = {}) + } + } + } + + private fun capture( + fileName: String, + rtl: Boolean = false, + fontScale: Float = 1f, + verifyTouchTargets: Boolean = true, + content: @Composable () -> Unit, + ) { + compose.setContent { + ScreenshotFrame(rtl = rtl, fontScale = fontScale, content = content) + } + compose.waitForIdle() + if (verifyTouchTargets) assertTouchTargets() + compose.onRoot().captureRoboImage(fileName) + } + private fun assertTouchTargets() { val minimum = with(compose.density) { 48.dp.toPx() } compose.onAllNodes(hasClickAction()).fetchSemanticsNodes().forEach { node -> @@ -90,11 +134,74 @@ class TodayScreenshotRegressionTest { } @Composable -private fun ScreenshotFrame(content: @Composable () -> Unit) { +private fun DashboardFixture() { + DashboardEditSheet( + state = TodayUiState( + editPins = listOf(TodayMetric.Calories, TodayMetric.Steps, TodayMetric.Water), + stepGoalInput = "10000", + sessionTargetInput = "3", + ), + onTogglePin = {}, + onMovePin = { _, _ -> }, + onStepGoalChanged = {}, + onSessionTargetChanged = {}, + onSave = {}, + onDismiss = {}, + ) +} + +private fun localizedVitals(locale: Locale): List { + val snapshot = MetricSnapshot( + caloriesKcal = 1_443.0, + calorieGoalKcal = 2_450.0, + proteinGrams = 118.0, + proteinGoalGrams = 180.0, + carbsGrams = 210.0, + carbsGoalGrams = 300.0, + fatGrams = 65.0, + fatGoalGrams = 80.0, + waterMl = 1_800.0, + waterGoalMl = 2_600.0, + steps = 7_800L, + stepGoal = 10_000L, + latestWeightKg = 80.5, + weightDeltaKg = -0.3, + bodyFatPercent = 21.4, + bodyFatDelta = -0.2, + sessionsDone = 2, + sessionTarget = 3, + activeCaloriesKcal = 520.0, + sleepMinutes = 455L, + exerciseMinutes = 48L, + exerciseSessionCount = 1, + restingHeartRateBpm = 54L, + loggingStreakDays = 8, + ) + return listOf(TodayMetric.Calories, TodayMetric.Water, TodayMetric.Weight, TodayMetric.BodyFat).map { metric -> + MetricCardUiState( + metric = metric, + label = metric.presentationLabel(), + value = resolveMetricPresentation(metric, snapshot, locale), + ) + } +} + +private fun metricWithGoal(figure: String, caption: String, progress: Float) = MetricValueUiState.WithGoal( + figure = UiText.Verbatim(figure), + caption = UiText.Verbatim(caption), + progress = progress, +) + +@Composable +private fun ScreenshotFrame( + rtl: Boolean = false, + fontScale: Float = 1f, + content: @Composable () -> Unit, +) { val density = LocalDensity.current CompositionLocalProvider( - LocalDensity provides Density(density.density, 1f), - LocalLayoutDirection provides LayoutDirection.Ltr, + LocalDensity provides Density(density.density, fontScale), + LocalLayoutDirection provides if (rtl) LayoutDirection.Rtl else LayoutDirection.Ltr, ) { MusFitTheme(darkTheme = true) { Box(Modifier.fillMaxSize().background(MusFitTheme.colors.background)) { content() } diff --git a/feature/today/src/testInternalDebug/screenshots/today-dashboard-phone-pseudo-font-150.png b/feature/today/src/testInternalDebug/screenshots/today-dashboard-phone-pseudo-font-150.png new file mode 100644 index 0000000000000000000000000000000000000000..d44511235616923f7562b37c4e19c8880deb5e93 GIT binary patch literal 25065 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 IU$wpWUwoiy%m4rY literal 0 HcmV?d00001 diff --git a/feature/today/src/testInternalDebug/screenshots/today-dashboard-phone-pseudo-rtl.png b/feature/today/src/testInternalDebug/screenshots/today-dashboard-phone-pseudo-rtl.png new file mode 100644 index 0000000000000000000000000000000000000000..2b501fe2e858309146235edf7798b2766b47abf7 GIT binary patch literal 18592 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 literal 0 HcmV?d00001 diff --git a/feature/today/src/testInternalDebug/screenshots/today-vitals-foldable-german.png b/feature/today/src/testInternalDebug/screenshots/today-vitals-foldable-german.png new file mode 100644 index 0000000000000000000000000000000000000000..6c9d606afd574dcd907807a6f7e6f3fe37ee9879 GIT binary patch literal 21839 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 literal 0 HcmV?d00001 diff --git a/integration/healthconnect/src/main/java/com/musfit/integrations/healthconnect/HealthPermissionInventory.kt b/integration/healthconnect/src/main/java/com/musfit/integrations/healthconnect/HealthPermissionInventory.kt index 8df9c16a..447e32de 100644 --- a/integration/healthconnect/src/main/java/com/musfit/integrations/healthconnect/HealthPermissionInventory.kt +++ b/integration/healthconnect/src/main/java/com/musfit/integrations/healthconnect/HealthPermissionInventory.kt @@ -19,6 +19,22 @@ enum class HealthPermissionAccess { Write, } +enum class HealthPermissionRationale { + Steps, + ActiveCalories, + TotalCalories, + Distance, + Sleep, + ExerciseSessions, + Weight, + BodyFat, + RestingHeartRate, + HeartRateVariability, + Workouts, + MealsAndNutrition, + Water, +} + internal enum class HealthPermissionRequestGroup { HealthAndWorkout, Food, @@ -27,8 +43,7 @@ internal enum class HealthPermissionRequestGroup { class HealthPermissionRationaleItem internal constructor( val permission: String, val access: HealthPermissionAccess, - val label: String, - val purpose: String, + val rationale: HealthPermissionRationale, private val requestGroup: HealthPermissionRequestGroup, ) { internal fun belongsTo(group: HealthPermissionRequestGroup): Boolean = requestGroup == group @@ -54,52 +69,44 @@ object HealthPermissionInventory { val writeHydrationPermission = HealthPermission.getWritePermission(HydrationRecord::class) val rationaleItems: List = listOf( - readItem(readStepsPermission, "Steps", "Show daily movement and progress."), + readItem(readStepsPermission, HealthPermissionRationale.Steps), readItem( readActiveCaloriesPermission, - "Active calories", - "Show energy burned through activity.", + HealthPermissionRationale.ActiveCalories, ), readItem( readTotalCaloriesPermission, - "Total calories", - "Show total energy burned during the day.", + HealthPermissionRationale.TotalCalories, ), - readItem(readDistancePermission, "Distance", "Show distance-based activity progress."), - readItem(readSleepPermission, "Sleep", "Show sleep duration in your health summary."), + readItem(readDistancePermission, HealthPermissionRationale.Distance), + readItem(readSleepPermission, HealthPermissionRationale.Sleep), readItem( readExercisePermission, - "Exercise sessions", - "Show completed exercise and time spent training.", + HealthPermissionRationale.ExerciseSessions, ), - readItem(readWeightPermission, "Weight", "Show weight measurements and trends."), - readItem(readBodyFatPermission, "Body fat", "Show body-fat measurements and trends."), + readItem(readWeightPermission, HealthPermissionRationale.Weight), + readItem(readBodyFatPermission, HealthPermissionRationale.BodyFat), readItem( readRestingHeartRatePermission, - "Resting heart rate", - "Show resting heart-rate trends.", + HealthPermissionRationale.RestingHeartRate, ), readItem( readHeartRateVariabilityPermission, - "Heart rate variability", - "Show recovery trends from heart-rate variability.", + HealthPermissionRationale.HeartRateVariability, ), writeItem( permission = writeExercisePermission, - label = "Workouts", - purpose = "Save workouts you log in MusFit.", + rationale = HealthPermissionRationale.Workouts, requestGroup = HealthPermissionRequestGroup.HealthAndWorkout, ), writeItem( permission = writeNutritionPermission, - label = "Meals and nutrition", - purpose = "Save meals and their nutrition when you choose Food sync.", + rationale = HealthPermissionRationale.MealsAndNutrition, requestGroup = HealthPermissionRequestGroup.Food, ), writeItem( permission = writeHydrationPermission, - label = "Water", - purpose = "Save water totals when you choose Food sync.", + rationale = HealthPermissionRationale.Water, requestGroup = HealthPermissionRequestGroup.Food, ), ) @@ -114,26 +121,22 @@ object HealthPermissionInventory { private fun readItem( permission: String, - label: String, - purpose: String, + rationale: HealthPermissionRationale, ) = HealthPermissionRationaleItem( permission = permission, access = HealthPermissionAccess.Read, - label = label, - purpose = purpose, + rationale = rationale, requestGroup = HealthPermissionRequestGroup.HealthAndWorkout, ) private fun writeItem( permission: String, - label: String, - purpose: String, + rationale: HealthPermissionRationale, requestGroup: HealthPermissionRequestGroup, ) = HealthPermissionRationaleItem( permission = permission, access = HealthPermissionAccess.Write, - label = label, - purpose = purpose, + rationale = rationale, requestGroup = requestGroup, ) } From 5f00a71406f81eeedd4f6e80cdff28bc088fbdc2 Mon Sep 17 00:00:00 2001 From: Moustafa Attia <72319345+attmous@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:21:26 +0200 Subject: [PATCH 3/4] fix(profile): preserve weight hero at large text --- .../com/musfit/ui/profile/ProfileScreen.kt | 150 ++++++++++++------ .../ProfileScreenshotRegressionTest.kt | 28 ++++ ...file-weight-hero-phone-pseudo-font-150.png | Bin 0 -> 28386 bytes 3 files changed, 130 insertions(+), 48 deletions(-) create mode 100644 feature/profile/src/testInternalDebug/screenshots/profile-weight-hero-phone-pseudo-font-150.png diff --git a/feature/profile/src/main/java/com/musfit/ui/profile/ProfileScreen.kt b/feature/profile/src/main/java/com/musfit/ui/profile/ProfileScreen.kt index e2b0eb76..86b0ec94 100644 --- a/feature/profile/src/main/java/com/musfit/ui/profile/ProfileScreen.kt +++ b/feature/profile/src/main/java/com/musfit/ui/profile/ProfileScreen.kt @@ -42,6 +42,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight @@ -318,31 +319,7 @@ private fun WeightHeroCard( verticalArrangement = Arrangement.spacedBy(10.dp), ) { if (hero.hasAnyEntry) { - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) { - Column(modifier = Modifier.weight(1f)) { - Row(verticalAlignment = Alignment.Bottom) { - // hasAnyEntry ⇔ latestWeightKg != null by construction (both from the same series). - Text( - hero.latestWeightKg!!.format1(LocalConfiguration.current.locales[0]), - style = MusFitTheme.typography.displayLarge.copy(fontSize = 52.sp, lineHeight = 52.sp), - color = accent.onContainer, - maxLines = 1, - ) - Text( - stringResource(R.string.profile_unit_kg), - style = MusFitTheme.typography.titleLarge.copy(fontSize = 19.sp), - fontWeight = FontWeight.Medium, - color = accent.onContainer, - maxLines = 1, - modifier = Modifier.padding(start = 6.dp, bottom = 6.dp), - ) - } - WeightMetaLine(hero = hero, accent = accent) - } - if (hero.goalWeightKg != null) { - HeroChip(text = goalChipText(state), accent = accent) - } - } + WeightHeroSummary(state = state, accent = accent) when { hero.chartSeries.size >= 2 -> ProfileTrendChart( @@ -372,30 +349,107 @@ private fun WeightHeroCard( color = accent.onContainerVariant, ) } - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Text( - text = if (hero.hasAnyEntry) { - hero.goalProgressFraction?.let { - stringResource( - R.string.profile_30_days_to_goal, - LocalizedFormatter.integer((it * 100).roundToInt().toLong(), locale = LocalConfiguration.current.locales[0]), - ) - } ?: stringResource(R.string.profile_30_days) - } else { - "" - }, - style = MusFitTheme.typography.bodySmall.copy(fontSize = 12.sp), - color = accent.onContainerVariant, - modifier = Modifier.weight(1f), - ) - HeroActionPill( - text = stringResource(R.string.profile_log_weight), - icon = Icons.Outlined.Add, - accent = accent, - onClick = onLogWeight, - ) - } + WeightHeroFooter(hero = hero, accent = accent, onLogWeight = onLogWeight) + } + } +} + +@Composable +internal fun WeightHeroCardPreview( + state: ProfileUiState, + accent: TabAccent, + onOpenEntries: () -> Unit, + onLogWeight: () -> Unit, +) = WeightHeroCard(state, accent, onOpenEntries, onLogWeight) + +@Composable +private fun WeightHeroFooter( + hero: WeightHeroState, + accent: TabAccent, + onLogWeight: () -> Unit, +) { + val progressText = if (hero.hasAnyEntry) { + hero.goalProgressFraction?.let { + stringResource( + R.string.profile_30_days_to_goal, + LocalizedFormatter.integer((it * 100).roundToInt().toLong(), locale = LocalConfiguration.current.locales[0]), + ) + } ?: stringResource(R.string.profile_30_days) + } else { + "" + } + val action: @Composable () -> Unit = { + HeroActionPill( + text = stringResource(R.string.profile_log_weight), + icon = Icons.Outlined.Add, + accent = accent, + onClick = onLogWeight, + ) + } + if (LocalDensity.current.fontScale >= 1.3f) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(progressText, style = MusFitTheme.typography.bodySmall, color = accent.onContainerVariant) + action() + } + } else { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text( + progressText, + style = MusFitTheme.typography.bodySmall.copy(fontSize = 12.sp), + color = accent.onContainerVariant, + modifier = Modifier.weight(1f), + ) + action() + } + } +} + +@Composable +private fun WeightHeroSummary(state: ProfileUiState, accent: TabAccent) { + val hero = state.hero + val goalChip: @Composable () -> Unit = { + if (hero.goalWeightKg != null) { + HeroChip(text = goalChipText(state), accent = accent) + } + } + if (LocalDensity.current.fontScale >= 1.3f) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + WeightHeroValue(hero = hero, accent = accent) + goalChip() + } + } else { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) { + WeightHeroValue(hero = hero, accent = accent, modifier = Modifier.weight(1f)) + goalChip() + } + } +} + +@Composable +private fun WeightHeroValue( + hero: WeightHeroState, + accent: TabAccent, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Row(verticalAlignment = Alignment.Bottom) { + // hasAnyEntry ⇔ latestWeightKg != null by construction (both from the same series). + Text( + hero.latestWeightKg!!.format1(LocalConfiguration.current.locales[0]), + style = MusFitTheme.typography.displayLarge.copy(fontSize = 52.sp, lineHeight = 52.sp), + color = accent.onContainer, + maxLines = 1, + ) + Text( + stringResource(R.string.profile_unit_kg), + style = MusFitTheme.typography.titleLarge.copy(fontSize = 19.sp), + fontWeight = FontWeight.Medium, + color = accent.onContainer, + maxLines = 1, + modifier = Modifier.padding(start = 6.dp, bottom = 6.dp), + ) } + WeightMetaLine(hero = hero, accent = accent) } } diff --git a/feature/profile/src/testInternalDebug/java/com/musfit/ui/profile/ProfileScreenshotRegressionTest.kt b/feature/profile/src/testInternalDebug/java/com/musfit/ui/profile/ProfileScreenshotRegressionTest.kt index ff638e4b..38cf4664 100644 --- a/feature/profile/src/testInternalDebug/java/com/musfit/ui/profile/ProfileScreenshotRegressionTest.kt +++ b/feature/profile/src/testInternalDebug/java/com/musfit/ui/profile/ProfileScreenshotRegressionTest.kt @@ -3,6 +3,7 @@ package com.musfit.ui.profile import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier @@ -12,6 +13,7 @@ import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onRoot import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp import com.github.takahirom.roborazzi.captureRoboImage import com.musfit.data.repository.DEFAULT_USER_PROFILE import com.musfit.domain.profile.GoalType @@ -52,6 +54,32 @@ class ProfileScreenshotRegressionTest { } } + @Test + @Config(qualifiers = "en-rXA-w400dp-h800dp-mdpi") + fun profileWeightHero_phone_pseudo_largeFont() { + capture("profile-weight-hero-phone-pseudo-font-150.png", fontScale = 1.5f) { + Box(Modifier.padding(16.dp)) { + WeightHeroCardPreview( + state = ProfileUiState( + profile = DEFAULT_USER_PROFILE.copy(goalType = GoalType.Gain, goalPaceKgPerWeek = 0.2), + hero = WeightHeroState( + latestWeightKg = 80.5, + deltaKg = 0.5, + goalWeightKg = 82.0, + goalProgressFraction = 0.5, + bmi = 25.0, + chartSeries = listOf(79.5, 80.0, 80.5), + hasAnyEntry = true, + ), + ), + accent = tabAccentFor(TabAccentRole.Profile), + onOpenEntries = {}, + onLogWeight = {}, + ) + } + } + } + @Test @Config(qualifiers = "ar-rXB-w400dp-h800dp-mdpi") fun profileSettings_phone_pseudoRtl() { diff --git a/feature/profile/src/testInternalDebug/screenshots/profile-weight-hero-phone-pseudo-font-150.png b/feature/profile/src/testInternalDebug/screenshots/profile-weight-hero-phone-pseudo-font-150.png new file mode 100644 index 0000000000000000000000000000000000000000..8a38425029210fcea298f334ce0e8f8e85bdcde3 GIT binary patch literal 28386 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 YkWaIT+3b}$q}}L_y1rVOirusS1tp`PxBvhE literal 0 HcmV?d00001 From 8ae75db2c94ca6c50cd4b812ce0461a363a55d99 Mon Sep 17 00:00:00 2001 From: Moustafa Attia <72319345+attmous@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:59:28 +0200 Subject: [PATCH 4/4] test(workflow): register S18 screenshot goldens --- scripts/dev/test-dev-workflow.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/dev/test-dev-workflow.ps1 b/scripts/dev/test-dev-workflow.ps1 index dbcf4c95..f6f2e8d9 100644 --- a/scripts/dev/test-dev-workflow.ps1 +++ b/scripts/dev/test-dev-workflow.ps1 @@ -716,7 +716,7 @@ $goldenCount = @( "feature/today/src/testInternalDebug/screenshots" | ForEach-Object { Get-ChildItem -LiteralPath (Get-RepoPath $_) -Filter "*.png" -File } ).Count -Assert-Equal "Reviewed screenshot golden count" 15 $goldenCount +Assert-Equal "Reviewed screenshot golden count" 24 $goldenCount # W2-PERF-01: production-shaped Macrobenchmark and app-owned Baseline Profile. Assert-FileExists "benchmark/build.gradle.kts"