diff --git a/app/src/androidTest/java/foundation/e/apps/ui/compose/components/SearchResultsContentTest.kt b/app/src/androidTest/java/foundation/e/apps/ui/compose/components/SearchResultsContentTest.kt index d97dc5625fd10f260fb26dda4c2d48d374694e5b..3db6387b52cb807c41117c848f1777225ea08206 100644 --- a/app/src/androidTest/java/foundation/e/apps/ui/compose/components/SearchResultsContentTest.kt +++ b/app/src/androidTest/java/foundation/e/apps/ui/compose/components/SearchResultsContentTest.kt @@ -53,7 +53,7 @@ import foundation.e.apps.ui.compose.state.InstallButtonAction import foundation.e.apps.ui.compose.state.InstallButtonState import foundation.e.apps.ui.compose.theme.AppTheme import foundation.e.apps.ui.search.v2.SearchTabType -import java.util.Locale +import foundation.e.apps.ui.utils.AppUserRatingFormatter @RunWith(AndroidJUnit4::class) class SearchResultsContentTest { @@ -137,8 +137,9 @@ class SearchResultsContentTest { fun applicationMapping_setsAuthorRatingAndPrimaryAction() { val notAvailable = composeRule.activity.getString(R.string.not_available) val openLabel = composeRule.activity.getString(R.string.open) - val expectedRating = String.format(Locale.getDefault(), "%.1f", 4.4) - val unexpectedRating = String.format(Locale.getDefault(), "%.1f", 4.9) + val locale = composeRule.activity.resources.configuration.locales[0] + val expectedRating = AppUserRatingFormatter.format(4.4, locale) + val hiddenRating = AppUserRatingFormatter.format(4.9, locale) renderSearchResults( tabs = listOf(SearchTabType.OPEN_SOURCE), @@ -181,10 +182,10 @@ class SearchResultsContentTest { ) composeRule.onNodeWithText("com.example.rated").assertIsDisplayed() - composeRule.onNodeWithText(expectedRating).assertIsDisplayed() + composeRule.onNodeWithText(expectedRating ?: "").assertIsDisplayed() composeRule.onNodeWithText(openLabel).assertIsDisplayed() composeRule.onNodeWithText(notAvailable).assertIsDisplayed() - composeRule.onAllNodesWithText(unexpectedRating).assertCountEquals(0) + composeRule.onAllNodesWithText(hiddenRating ?: "").assertCountEquals(0) } @Test diff --git a/app/src/main/java/foundation/e/apps/data/Constants.kt b/app/src/main/java/foundation/e/apps/data/Constants.kt index a06cc5737e98b748829b15f6116696dd567ce155..63fb6553e023004016c9d8689ca1e818c6e2eacc 100644 --- a/app/src/main/java/foundation/e/apps/data/Constants.kt +++ b/app/src/main/java/foundation/e/apps/data/Constants.kt @@ -37,6 +37,4 @@ object Constants { "${BuildConfig.PACKAGE_NAME_PARENTAL_CONTROL}.action.APP_LOUNGE_LOGIN" const val REQUEST_GPLAY_LOGIN = "request_gplay_login" - - const val MIN_VALID_RATING = 0.1 } diff --git a/app/src/main/java/foundation/e/apps/data/application/data/Ratings.kt b/app/src/main/java/foundation/e/apps/data/application/data/Ratings.kt index 5205b82415c5c51b2129e038441a60f9afb569e2..5e7353ffc81617b348c2628de720ec4fb22d4c89 100644 --- a/app/src/main/java/foundation/e/apps/data/application/data/Ratings.kt +++ b/app/src/main/java/foundation/e/apps/data/application/data/Ratings.kt @@ -19,6 +19,5 @@ package foundation.e.apps.data.application.data data class Ratings( - val privacyScore: Double = -1.0, val usageQualityScore: Double = -1.0 ) diff --git a/app/src/main/java/foundation/e/apps/data/install/AppManagerWrapper.kt b/app/src/main/java/foundation/e/apps/data/install/AppManagerWrapper.kt index 8fd79c474d2c01257a21aade59a79919f6dbd69b..87f9bc4ea2a374be41a8fda9fafc3dc851f38ca1 100644 --- a/app/src/main/java/foundation/e/apps/data/install/AppManagerWrapper.kt +++ b/app/src/main/java/foundation/e/apps/data/install/AppManagerWrapper.kt @@ -22,7 +22,6 @@ import android.content.Context import androidx.lifecycle.LiveData import dagger.hilt.android.qualifiers.ApplicationContext import foundation.e.apps.OpenForTesting -import foundation.e.apps.data.Constants.MIN_VALID_RATING import foundation.e.apps.data.application.data.Application import foundation.e.apps.data.fdroid.FDroidRepository import foundation.e.apps.data.install.download.data.DownloadProgress @@ -171,18 +170,6 @@ class AppManagerWrapper @Inject constructor( return percent } - fun handleRatingFormat(rating: Double): String? { - return if (rating >= MIN_VALID_RATING) { - if (rating % 1 == 0.0) { - rating.toInt().toString() - } else { - rating.toString() - } - } else { - null - } - } - suspend fun getCalculateProgressWithTotalSize( application: Application?, progress: DownloadProgress diff --git a/app/src/main/java/foundation/e/apps/ui/MainActivityViewModel.kt b/app/src/main/java/foundation/e/apps/ui/MainActivityViewModel.kt index 236a1ef017da7deb5b69c26a20d63dec8b73c153..b7e2cd50deb4a2c1dde35580e897d62a5ec2cc29 100644 --- a/app/src/main/java/foundation/e/apps/ui/MainActivityViewModel.kt +++ b/app/src/main/java/foundation/e/apps/ui/MainActivityViewModel.kt @@ -397,8 +397,4 @@ class MainActivityViewModel @Inject constructor( fun launchPwa(homeApp: ApplicationDomain) { launchPwa(homeApp.toApplication()) } - - fun handleRatingFormat(rating: Double): String? { - return appManagerWrapper.handleRatingFormat(rating) - } } diff --git a/app/src/main/java/foundation/e/apps/ui/application/ApplicationFragment.kt b/app/src/main/java/foundation/e/apps/ui/application/ApplicationFragment.kt index 047850460ff04db0deb34c1635e6ff36d4b3598f..df1a74499713051e0867f9acea0b37ca549c1996 100644 --- a/app/src/main/java/foundation/e/apps/ui/application/ApplicationFragment.kt +++ b/app/src/main/java/foundation/e/apps/ui/application/ApplicationFragment.kt @@ -75,6 +75,7 @@ import foundation.e.apps.ui.application.ShareButtonVisibilityState.Visible import foundation.e.apps.ui.application.model.ApplicationScreenshotsRVAdapter import foundation.e.apps.ui.application.subFrags.ApplicationDialogFragment import foundation.e.apps.ui.parentFragment.TimeoutFragment +import foundation.e.apps.ui.utils.AppUserRatingFormatter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @@ -357,13 +358,14 @@ class ApplicationFragment : TimeoutFragment(R.layout.fragment_application) { private fun updateAppRating(it: Application) { binding.ratingsInclude.apply { - val formattedRating = applicationViewModel.handleRatingFormat(it.ratings.usageQualityScore) + val locale = resources.configuration.locales[0] + val formattedRating = AppUserRatingFormatter.format(it.ratings.usageQualityScore, locale) if (formattedRating != null) { appRating.text = getString(R.string.rating_out_of, formattedRating) appRating.setCompoundDrawablesWithIntrinsicBounds( ContextCompat.getDrawable(requireContext(), R.drawable.ic_star_blank), null, - getRatingDrawable(formattedRating), + getRatingDrawable(it.ratings.usageQualityScore), null ) appRating.compoundDrawablePadding = DRAWABLE_PADDING @@ -1075,9 +1077,7 @@ class ApplicationFragment : TimeoutFragment(R.layout.fragment_application) { return applyDotAccent(dotColor) } - private fun getRatingDrawable(reviewRating: String): Drawable? { - val rating = reviewRating.toDouble() - + private fun getRatingDrawable(rating: Double): Drawable? { var dotColor = ContextCompat.getColor(requireContext(), R.color.colorGreen) if (rating <= LOW_REVIEW_THRESHOLD) { dotColor = ContextCompat.getColor(requireContext(), R.color.colorRed) diff --git a/app/src/main/java/foundation/e/apps/ui/application/ApplicationViewModel.kt b/app/src/main/java/foundation/e/apps/ui/application/ApplicationViewModel.kt index 8c12820cb02c85e29c6f8ff4e1e7d520a9c81b0d..de36a579a46b40e7635b45feb4eae949e3a790ab 100644 --- a/app/src/main/java/foundation/e/apps/ui/application/ApplicationViewModel.kt +++ b/app/src/main/java/foundation/e/apps/ui/application/ApplicationViewModel.kt @@ -213,11 +213,6 @@ class ApplicationViewModel @Inject constructor( fun getApplication(): Application? { return applicationLiveData.value?.first } - - fun handleRatingFormat(rating: Double): String? { - return appManagerWrapper.handleRatingFormat(rating) - } - suspend fun calculateProgress(progress: DownloadProgress): Pair { return appManagerWrapper.getCalculateProgressWithTotalSize( applicationLiveData.value?.first, diff --git a/app/src/main/java/foundation/e/apps/ui/applicationlist/ApplicationListRVAdapter.kt b/app/src/main/java/foundation/e/apps/ui/applicationlist/ApplicationListRVAdapter.kt index 62a0b19f1df39d79c58ecb4179c562af319ac543..161924bde01095fd42cd42de073cb3400b6fdb39 100644 --- a/app/src/main/java/foundation/e/apps/ui/applicationlist/ApplicationListRVAdapter.kt +++ b/app/src/main/java/foundation/e/apps/ui/applicationlist/ApplicationListRVAdapter.kt @@ -53,6 +53,7 @@ import foundation.e.apps.ui.PrivacyInfoViewModel import foundation.e.apps.ui.applicationlist.diffUtils.ConciseAppDiffUtils import foundation.e.apps.ui.search.SearchFragmentDirections import foundation.e.apps.ui.updates.UpdatesFragmentDirections +import foundation.e.apps.ui.utils.AppUserRatingFormatter import foundation.e.apps.ui.utils.disableInstallButton import foundation.e.apps.ui.utils.enableInstallButton import kotlinx.coroutines.launch @@ -211,7 +212,8 @@ class ApplicationListRVAdapter( appRating.isVisible = false return } - val formattedRating = mainActivityViewModel.handleRatingFormat(searchApp.ratings.usageQualityScore) + val locale = root.context.resources.configuration.locales[0] + val formattedRating = AppUserRatingFormatter.format(searchApp.ratings.usageQualityScore, locale) appRating.text = formattedRating ?: root.context.getString(R.string.not_available) } diff --git a/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultListItem.kt b/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultListItem.kt index 31d77299d823a79a07ff4c208bda80ad8dd43fc6..ef323db775dc176c65a4563ad41d66a4fa3dd6ff 100644 --- a/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultListItem.kt +++ b/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultListItem.kt @@ -52,6 +52,7 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.PlatformTextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -163,14 +164,18 @@ private fun AppIcon( private fun RatingChip(ratingText: String) { Row(verticalAlignment = Alignment.CenterVertically) { Image( + modifier = Modifier.padding(bottom = 2.dp), painter = painterResource(R.drawable.ic_star), contentDescription = stringResource(id = R.string.rating), - modifier = Modifier.size(16.dp), ) Spacer(modifier = Modifier.width(4.dp)) Text( text = ratingText, - style = MaterialTheme.typography.bodyMedium, + style = MaterialTheme.typography.bodyMedium.copy( + fontSize = 13.sp, + lineHeight = 13.sp, + platformStyle = PlatformTextStyle(includeFontPadding = false), + ), color = MaterialTheme.colorScheme.onBackground, ) } diff --git a/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultsContent.kt b/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultsContent.kt index 0c7b428b772975ff9c33091f37878ff4e6770c96..458f3e0a20ddd376458c1f18cc861ec69b86da58 100644 --- a/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultsContent.kt +++ b/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultsContent.kt @@ -42,6 +42,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -58,9 +59,9 @@ import foundation.e.apps.ui.compose.state.InstallButtonState import foundation.e.apps.ui.compose.state.InstallButtonStyle import foundation.e.apps.ui.search.v2.ScrollPosition import foundation.e.apps.ui.search.v2.SearchTabType +import foundation.e.apps.ui.utils.AppUserRatingFormatter import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import java.util.Locale @Composable fun SearchResultsContent( @@ -553,15 +554,11 @@ private fun Application.toSearchResultUiState(buttonState: InstallButtonState): ) } + val locale = LocalConfiguration.current.locales[0] val ratingText = when { source == Source.OPEN_SOURCE || source == Source.PWA || isSystemApp -> "" - ratings.usageQualityScore >= 0 -> String.format( - Locale.getDefault(), - "%.1f", - ratings.usageQualityScore - ) - - else -> stringResource(id = R.string.not_available) + else -> AppUserRatingFormatter.format(ratings.usageQualityScore, locale) + ?: stringResource(R.string.not_available) } return SearchResultListItemState( diff --git a/app/src/main/java/foundation/e/apps/ui/utils/AppUserRatingFormatter.kt b/app/src/main/java/foundation/e/apps/ui/utils/AppUserRatingFormatter.kt new file mode 100644 index 0000000000000000000000000000000000000000..06b9ca7d954da58e8a223d0321bdc66abfda754f --- /dev/null +++ b/app/src/main/java/foundation/e/apps/ui/utils/AppUserRatingFormatter.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2026 e Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package foundation.e.apps.ui.utils + +import java.text.NumberFormat +import java.util.Locale + +object AppUserRatingFormatter { + + private const val MIN_VALID_RATING = 0.1 + + fun format(rating: Double?, locale: Locale): String? { + if (rating == null || rating < MIN_VALID_RATING) { + return null + } + + return NumberFormat.getNumberInstance(locale).apply { + minimumFractionDigits = 0 + maximumFractionDigits = 1 + isGroupingUsed = false + }.format(rating) + } +} diff --git a/app/src/test/java/foundation/e/apps/ui/utils/AppUserRatingFormatterTest.kt b/app/src/test/java/foundation/e/apps/ui/utils/AppUserRatingFormatterTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..28c0d933c9ddfba96cfdca259a6caec4db3f61e2 --- /dev/null +++ b/app/src/test/java/foundation/e/apps/ui/utils/AppUserRatingFormatterTest.kt @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2026 e Foundation + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package foundation.e.apps.ui.utils + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.util.Locale + +class AppUserRatingFormatterTest { + + private val usLocale = Locale.US + private val franceLocale = Locale.FRANCE + + @Test + fun format_returnsNull_whenRatingIsNull() { + assertNull(AppUserRatingFormatter.format(null, usLocale)) + } + + @Test + fun format_returnsNull_whenRatingIsBelowMinimum() { + assertNull(AppUserRatingFormatter.format(-1.0, usLocale)) + assertNull(AppUserRatingFormatter.format(0.0, usLocale)) + } + + @Test + fun format_returnsWholeNumberWithoutFraction_forUsLocale() { + assertEquals("4", AppUserRatingFormatter.format(4.0, usLocale)) + } + + @Test + fun format_returnsDecimalWithDot_forUsLocale() { + assertEquals("4.4", AppUserRatingFormatter.format(4.4, usLocale)) + } + + @Test + fun format_returnsDecimalWithComma_forFrenchLocale() { + assertEquals("4,4", AppUserRatingFormatter.format(4.4, franceLocale)) + } + + @Test + fun format_roundsUnexpectedPrecision_forUsLocale() { + assertEquals("4.3", AppUserRatingFormatter.format(4.34, usLocale)) + assertEquals("4.4", AppUserRatingFormatter.format(4.36, usLocale)) + } + + @Test + fun format_roundsUnexpectedPrecision_forFrenchLocale() { + assertEquals("4,3", AppUserRatingFormatter.format(4.34, franceLocale)) + assertEquals("4,4", AppUserRatingFormatter.format(4.36, franceLocale)) + } +}