From 7c1e3c17592d69683ccb17176aacfe9df78d810d Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Fri, 27 Mar 2026 16:35:57 +0600 Subject: [PATCH 1/6] fix: show "N/A" user rating in search results with 0 rating apps Centralize app user rating formatting in AppUserRatingFormatter so Compose and non-Compose UI use the same raw rating rules. Remove the old wrapper/viewmodel pass-through methods and cover the shared formatter with tests. --- .../components/SearchResultsContentTest.kt | 9 ++-- .../java/foundation/e/apps/data/Constants.kt | 2 - .../e/apps/data/application/data/Ratings.kt | 1 - .../e/apps/data/install/AppManagerWrapper.kt | 13 ------ .../e/apps/ui/MainActivityViewModel.kt | 4 -- .../ui/application/ApplicationFragment.kt | 3 +- .../ui/application/ApplicationViewModel.kt | 5 -- .../ApplicationListRVAdapter.kt | 3 +- .../components/SearchResultsContent.kt | 11 ++--- .../e/apps/ui/utils/AppUserRatingFormatter.kt | 35 ++++++++++++++ .../ui/utils/AppUserRatingFormatterTest.kt | 46 +++++++++++++++++++ 11 files changed, 92 insertions(+), 40 deletions(-) create mode 100644 app/src/main/java/foundation/e/apps/ui/utils/AppUserRatingFormatter.kt create mode 100644 app/src/test/java/foundation/e/apps/ui/utils/AppUserRatingFormatterTest.kt 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 d97dc5625..e5b3dd700 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,6 @@ 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 @RunWith(AndroidJUnit4::class) class SearchResultsContentTest { @@ -137,8 +136,8 @@ 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 expectedRating = "4" + val hiddenRating = "4.9" renderSearchResults( tabs = listOf(SearchTabType.OPEN_SOURCE), @@ -150,7 +149,7 @@ class SearchResultsContentTest { author = "", package_name = "com.example.rated", source = Source.PLAY_STORE, - ratings = Ratings(usageQualityScore = 4.4), + ratings = Ratings(usageQualityScore = 4.0), status = Status.INSTALLED, ), Application( @@ -184,7 +183,7 @@ class SearchResultsContentTest { 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 a06cc5737..63fb6553e 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 5205b8241..5e7353ffc 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 8fd79c474..87f9bc4ea 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 236a1ef01..b7e2cd50d 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 f5f65d2aa..a009b1bc0 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 @@ -76,6 +76,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 @@ -358,7 +359,7 @@ class ApplicationFragment : TimeoutFragment(R.layout.fragment_application) { private fun updateAppRating(it: Application) { binding.ratingsInclude.apply { - val formattedRating = applicationViewModel.handleRatingFormat(it.ratings.usageQualityScore) + val formattedRating = AppUserRatingFormatter.format(it.ratings.usageQualityScore) if (formattedRating != null) { appRating.text = getString(R.string.rating_out_of, formattedRating) appRating.setCompoundDrawablesWithIntrinsicBounds( 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 8c12820cb..de36a579a 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 62a0b19f1..68e92959b 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,7 @@ class ApplicationListRVAdapter( appRating.isVisible = false return } - val formattedRating = mainActivityViewModel.handleRatingFormat(searchApp.ratings.usageQualityScore) + val formattedRating = AppUserRatingFormatter.format(searchApp.ratings.usageQualityScore) appRating.text = formattedRating ?: root.context.getString(R.string.not_available) } 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 0c7b428b7..7ec728af2 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 @@ -58,9 +58,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( @@ -555,13 +555,8 @@ private fun Application.toSearchResultUiState(buttonState: InstallButtonState): 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) + ?: 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 000000000..952c2f5c5 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/ui/utils/AppUserRatingFormatter.kt @@ -0,0 +1,35 @@ +/* + * 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 + +object AppUserRatingFormatter { + + private const val MIN_VALID_RATING = 0.1 + + fun format(rating: Double?): String? { + if (rating == null || rating < MIN_VALID_RATING) { + return null + } + + return if (rating % 1 == 0.0) { + rating.toInt().toString() + } else { + rating.toString() + } + } +} 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 000000000..ca68d1d4a --- /dev/null +++ b/app/src/test/java/foundation/e/apps/ui/utils/AppUserRatingFormatterTest.kt @@ -0,0 +1,46 @@ +/* + * 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 + +class AppUserRatingFormatterTest { + + @Test + fun format_returnsNull_whenRatingIsNull() { + assertNull(AppUserRatingFormatter.format(null)) + } + + @Test + fun format_returnsNull_whenRatingIsBelowMinimum() { + assertNull(AppUserRatingFormatter.format(-1.0)) + assertNull(AppUserRatingFormatter.format(0.0)) + } + + @Test + fun format_returnsIntegerString_whenRatingIsWholeNumber() { + assertEquals("4", AppUserRatingFormatter.format(4.0)) + } + + @Test + fun format_returnsDecimalString_whenRatingHasFraction() { + assertEquals("4.4", AppUserRatingFormatter.format(4.4)) + } +} -- GitLab From 2444c4dbae46ac07077635a93757f4ee333bb433 Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Fri, 27 Mar 2026 17:02:13 +0600 Subject: [PATCH 2/6] refactor: improve UI in search results list item Rating icon and text are vertically centered now. --- .../ui/compose/components/SearchResultListItem.kt | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) 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 31d77299d..81764f0bf 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,20 @@ private fun AppIcon( private fun RatingChip(ratingText: String) { Row(verticalAlignment = Alignment.CenterVertically) { Image( + modifier = Modifier + .size(14.dp) + .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, ) } -- GitLab From 158bf82260e1b31257bdc132451efb2e7f0cf157 Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Fri, 27 Mar 2026 18:59:30 +0600 Subject: [PATCH 3/6] refactor: add locale-aware formatting for user rating --- .../components/SearchResultsContentTest.kt | 9 +++-- .../ui/application/ApplicationFragment.kt | 16 ++++---- .../ApplicationListRVAdapter.kt | 8 +++- .../components/SearchResultsContent.kt | 7 +++- .../e/apps/ui/utils/AppUserRatingFormatter.kt | 28 ++++++++----- .../ui/utils/AppUserRatingFormatterTest.kt | 40 +++++++++++++++---- 6 files changed, 80 insertions(+), 28 deletions(-) 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 e5b3dd700..074b47223 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,6 +53,8 @@ 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 foundation.e.apps.ui.utils.AppUserRatingFormatter +import java.util.Locale @RunWith(AndroidJUnit4::class) class SearchResultsContentTest { @@ -136,8 +138,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 = "4" - val hiddenRating = "4.9" + val locale = Locale.FRANCE + val expectedRating = AppUserRatingFormatter.format(4.4, locale) ?: notAvailable + val hiddenRating = AppUserRatingFormatter.format(4.9, locale) ?: notAvailable renderSearchResults( tabs = listOf(SearchTabType.OPEN_SOURCE), @@ -149,7 +152,7 @@ class SearchResultsContentTest { author = "", package_name = "com.example.rated", source = Source.PLAY_STORE, - ratings = Ratings(usageQualityScore = 4.0), + ratings = Ratings(usageQualityScore = 4.4), status = Status.INSTALLED, ), Application( 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 a009b1bc0..129b0d43f 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 @@ -31,6 +31,7 @@ import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat import androidx.core.graphics.BlendModeColorFilterCompat import androidx.core.graphics.BlendModeCompat +import androidx.core.os.ConfigurationCompat import androidx.core.view.isVisible import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels @@ -359,13 +360,16 @@ class ApplicationFragment : TimeoutFragment(R.layout.fragment_application) { private fun updateAppRating(it: Application) { binding.ratingsInclude.apply { - val formattedRating = AppUserRatingFormatter.format(it.ratings.usageQualityScore) - if (formattedRating != null) { - appRating.text = getString(R.string.rating_out_of, formattedRating) + val locale = ConfigurationCompat.getLocales(resources.configuration).get(0) + ?: Locale.getDefault() + val numericRating = AppUserRatingFormatter.roundedNumericValue(it.ratings.usageQualityScore) + val localizedRating = AppUserRatingFormatter.format(numericRating, locale) + if (numericRating != null && localizedRating != null) { + appRating.text = getString(R.string.rating_out_of, localizedRating) appRating.setCompoundDrawablesWithIntrinsicBounds( ContextCompat.getDrawable(requireContext(), R.drawable.ic_star_blank), null, - getRatingDrawable(formattedRating), + getRatingDrawable(numericRating), null ) appRating.compoundDrawablePadding = DRAWABLE_PADDING @@ -1077,9 +1081,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/applicationlist/ApplicationListRVAdapter.kt b/app/src/main/java/foundation/e/apps/ui/applicationlist/ApplicationListRVAdapter.kt index 68e92959b..c4306726c 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 @@ -25,6 +25,7 @@ import android.view.View import android.view.ViewGroup import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat +import androidx.core.os.ConfigurationCompat import androidx.core.view.children import androidx.core.view.isVisible import androidx.lifecycle.LifecycleOwner @@ -58,6 +59,7 @@ import foundation.e.apps.ui.utils.disableInstallButton import foundation.e.apps.ui.utils.enableInstallButton import kotlinx.coroutines.launch import timber.log.Timber +import java.util.Locale import javax.inject.Singleton import foundation.e.elib.R as eR @@ -212,7 +214,11 @@ class ApplicationListRVAdapter( appRating.isVisible = false return } - val formattedRating = AppUserRatingFormatter.format(searchApp.ratings.usageQualityScore) + + val locale = ConfigurationCompat.getLocales(root.context.resources.configuration).get(0) + ?: Locale.getDefault() + 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/SearchResultsContent.kt b/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultsContent.kt index 7ec728af2..db215a579 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,9 +42,11 @@ 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 +import androidx.core.os.ConfigurationCompat import androidx.paging.LoadState import androidx.paging.compose.LazyPagingItems import foundation.e.apps.R @@ -61,6 +63,7 @@ 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,9 +556,11 @@ private fun Application.toSearchResultUiState(buttonState: InstallButtonState): ) } + val locale = ConfigurationCompat.getLocales(LocalConfiguration.current).get(0) + ?: Locale.getDefault() val ratingText = when { source == Source.OPEN_SOURCE || source == Source.PWA || isSystemApp -> "" - else -> AppUserRatingFormatter.format(ratings.usageQualityScore) + else -> AppUserRatingFormatter.format(ratings.usageQualityScore, locale) ?: stringResource(R.string.not_available) } 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 index 952c2f5c5..4c1f01163 100644 --- a/app/src/main/java/foundation/e/apps/ui/utils/AppUserRatingFormatter.kt +++ b/app/src/main/java/foundation/e/apps/ui/utils/AppUserRatingFormatter.kt @@ -17,19 +17,29 @@ package foundation.e.apps.ui.utils +import java.math.BigDecimal +import java.math.RoundingMode +import java.text.NumberFormat +import java.util.Locale + object AppUserRatingFormatter { private const val MIN_VALID_RATING = 0.1 - fun format(rating: Double?): String? { - if (rating == null || rating < MIN_VALID_RATING) { - return null - } + fun roundedNumericValue(rating: Double?): Double? { + val validRating = rating?.takeIf { it.isFinite() && it >= MIN_VALID_RATING } ?: return null + + return BigDecimal.valueOf(validRating).setScale(1, RoundingMode.HALF_EVEN).toDouble() + } + + fun format(rating: Double?, locale: Locale): String? { + val roundedRating = roundedNumericValue(rating) ?: return null - return if (rating % 1 == 0.0) { - rating.toInt().toString() - } else { - rating.toString() - } + return NumberFormat.getNumberInstance(locale).apply { + minimumFractionDigits = 0 + maximumFractionDigits = 1 + isGroupingUsed = false + roundingMode = RoundingMode.HALF_EVEN + }.format(roundedRating) } } 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 index ca68d1d4a..80967c754 100644 --- a/app/src/test/java/foundation/e/apps/ui/utils/AppUserRatingFormatterTest.kt +++ b/app/src/test/java/foundation/e/apps/ui/utils/AppUserRatingFormatterTest.kt @@ -20,27 +20,53 @@ 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)) + assertNull(AppUserRatingFormatter.format(null, usLocale)) } @Test fun format_returnsNull_whenRatingIsBelowMinimum() { - assertNull(AppUserRatingFormatter.format(-1.0)) - assertNull(AppUserRatingFormatter.format(0.0)) + assertNull(AppUserRatingFormatter.format(-1.0, usLocale)) + assertNull(AppUserRatingFormatter.format(0.0, usLocale)) + } + + @Test + fun format_returnsNull_whenRatingIsNaN() { + assertNull(AppUserRatingFormatter.format(Double.NaN, 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_returnsIntegerString_whenRatingIsWholeNumber() { - assertEquals("4", AppUserRatingFormatter.format(4.0)) + fun format_roundsUnexpectedPrecision_forUsLocale() { + assertEquals("4.3", AppUserRatingFormatter.format(4.34, usLocale)) + assertEquals("4.4", AppUserRatingFormatter.format(4.36, usLocale)) } @Test - fun format_returnsDecimalString_whenRatingHasFraction() { - assertEquals("4.4", AppUserRatingFormatter.format(4.4)) + fun format_roundsUnexpectedPrecision_forFrenchLocale() { + assertEquals("4,3", AppUserRatingFormatter.format(4.34, franceLocale)) + assertEquals("4,4", AppUserRatingFormatter.format(4.36, franceLocale)) } } -- GitLab From 385e374741ac7ec906dfa945083cb273cdf2f303 Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Mon, 30 Mar 2026 17:41:35 +0600 Subject: [PATCH 4/6] refactor: improve rounding in app's user rating --- .../foundation/e/apps/ui/utils/AppUserRatingFormatter.kt | 4 ++-- .../e/apps/ui/utils/AppUserRatingFormatterTest.kt | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) 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 index 4c1f01163..7c33d99a6 100644 --- a/app/src/main/java/foundation/e/apps/ui/utils/AppUserRatingFormatter.kt +++ b/app/src/main/java/foundation/e/apps/ui/utils/AppUserRatingFormatter.kt @@ -29,7 +29,7 @@ object AppUserRatingFormatter { fun roundedNumericValue(rating: Double?): Double? { val validRating = rating?.takeIf { it.isFinite() && it >= MIN_VALID_RATING } ?: return null - return BigDecimal.valueOf(validRating).setScale(1, RoundingMode.HALF_EVEN).toDouble() + return BigDecimal.valueOf(validRating).setScale(1, RoundingMode.HALF_UP).toDouble() } fun format(rating: Double?, locale: Locale): String? { @@ -39,7 +39,7 @@ object AppUserRatingFormatter { minimumFractionDigits = 0 maximumFractionDigits = 1 isGroupingUsed = false - roundingMode = RoundingMode.HALF_EVEN + roundingMode = RoundingMode.HALF_UP }.format(roundedRating) } } 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 index 80967c754..d83fbbaac 100644 --- a/app/src/test/java/foundation/e/apps/ui/utils/AppUserRatingFormatterTest.kt +++ b/app/src/test/java/foundation/e/apps/ui/utils/AppUserRatingFormatterTest.kt @@ -62,11 +62,17 @@ class AppUserRatingFormatterTest { fun format_roundsUnexpectedPrecision_forUsLocale() { assertEquals("4.3", AppUserRatingFormatter.format(4.34, usLocale)) assertEquals("4.4", AppUserRatingFormatter.format(4.36, usLocale)) + assertEquals("3.3", AppUserRatingFormatter.format(3.34, usLocale)) + assertEquals("3.4", AppUserRatingFormatter.format(3.35, usLocale)) + assertEquals("3.6", AppUserRatingFormatter.format(3.6, usLocale)) } @Test fun format_roundsUnexpectedPrecision_forFrenchLocale() { assertEquals("4,3", AppUserRatingFormatter.format(4.34, franceLocale)) assertEquals("4,4", AppUserRatingFormatter.format(4.36, franceLocale)) + assertEquals("3,3", AppUserRatingFormatter.format(3.34, franceLocale)) + assertEquals("3,4", AppUserRatingFormatter.format(3.35, franceLocale)) + assertEquals("3,6", AppUserRatingFormatter.format(3.6, franceLocale)) } } -- GitLab From 6c5a45f8a7c76f78fd45f2147408d0d8d143e18f Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Tue, 31 Mar 2026 12:44:13 +0600 Subject: [PATCH 5/6] refactor: reuse rating formatters in list rendering paths Before: - each rating format call created and configured a new NumberFormat - Compose list items also did locale lookup inside per-item UI-state mapping After: - the formatter is created once per relevant UI scope and reused for visible items - per-item work is reduced to formatting the value, not rebuilding formatter state each time Why this was done: - rating formatting happens during list rendering and rebinding - repeated NumberFormat allocation is avoidable work - caching at UI scope keeps the optimization local and avoids a shared global NumberFormat, which would be risky because it is mutable and not thread-safe --- .../ApplicationListRVAdapter.kt | 20 +++++++++++++- .../components/SearchResultsContent.kt | 26 +++++++++++++++---- .../e/apps/ui/utils/AppUserRatingFormatter.kt | 17 ++++++++---- .../ui/utils/AppUserRatingFormatterTest.kt | 8 ++++++ 4 files changed, 60 insertions(+), 11 deletions(-) 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 c4306726c..4ac4c7060 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 @@ -59,6 +59,7 @@ import foundation.e.apps.ui.utils.disableInstallButton import foundation.e.apps.ui.utils.enableInstallButton import kotlinx.coroutines.launch import timber.log.Timber +import java.text.NumberFormat import java.util.Locale import javax.inject.Singleton import foundation.e.elib.R as eR @@ -75,6 +76,8 @@ class ApplicationListRVAdapter( ) : ListAdapter(ConciseAppDiffUtils()) { private var optionalCategory = "" + private var ratingFormatterLocale: Locale? = null + private var ratingFormatter: NumberFormat? = null private val shimmer = Shimmer.ColorHighlightBuilder() .setDuration(SHIMMER_DURATION_MS) @@ -217,11 +220,26 @@ class ApplicationListRVAdapter( val locale = ConfigurationCompat.getLocales(root.context.resources.configuration).get(0) ?: Locale.getDefault() - val formattedRating = AppUserRatingFormatter.format(searchApp.ratings.usageQualityScore, locale) + val formattedRating = AppUserRatingFormatter.format( + searchApp.ratings.usageQualityScore, + getRatingFormatter(locale) + ) appRating.text = formattedRating ?: root.context.getString(R.string.not_available) } + private fun getRatingFormatter(locale: Locale): NumberFormat { + val currentFormatter = ratingFormatter + if (ratingFormatterLocale == locale && currentFormatter != null) { + return currentFormatter + } + + return AppUserRatingFormatter.newFormatter(locale).also { + ratingFormatterLocale = locale + ratingFormatter = it + } + } + private fun ApplicationListItemBinding.updateSourceTag(searchApp: Application) { sourceTag.visibility = View.INVISIBLE val tag = searchApp.source.toString(root.context::getString) 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 db215a579..030ee01e1 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 @@ -296,6 +296,20 @@ private fun PagingPlayStoreResultList( val hasLoadedCurrentQuery = remember(searchVersion) { mutableStateOf(false) } + val notAvailableMessage = stringResource(R.string.not_available) + + val locale = LocalConfiguration.current.let { configuration -> + ConfigurationCompat.getLocales(configuration).get(0) ?: Locale.getDefault() + } + + val ratingFormatter = remember(locale) { AppUserRatingFormatter.newFormatter(locale) } + + val formatRating = remember(ratingFormatter, notAvailableMessage) { + fun(rating: Double?): String { + return AppUserRatingFormatter.format(rating, ratingFormatter) ?: notAvailableMessage + } + } + LaunchedEffect(searchVersion, refreshState, lazyItems.itemCount) { if (refreshState is LoadState.NotLoading && lazyItems.itemCount > 0) { hasLoadedCurrentQuery.value = true @@ -350,7 +364,8 @@ private fun PagingPlayStoreResultList( val application = lazyItems[index] if (application != null) { val uiState = application.toSearchResultUiState( - installButtonStateProvider(application) + buttonState = installButtonStateProvider(application), + formatRating = formatRating, ) SearchResultListItem( application = application, @@ -538,7 +553,10 @@ private fun PagingSearchResultList( } @Composable -private fun Application.toSearchResultUiState(buttonState: InstallButtonState): SearchResultListItemState { +private fun Application.toSearchResultUiState( + buttonState: InstallButtonState, + formatRating: ((Double?) -> String)? = null, +): SearchResultListItemState { if (isPlaceHolder) { return SearchResultListItemState( author = "", @@ -556,11 +574,9 @@ private fun Application.toSearchResultUiState(buttonState: InstallButtonState): ) } - val locale = ConfigurationCompat.getLocales(LocalConfiguration.current).get(0) - ?: Locale.getDefault() val ratingText = when { source == Source.OPEN_SOURCE || source == Source.PWA || isSystemApp -> "" - else -> AppUserRatingFormatter.format(ratings.usageQualityScore, locale) + else -> formatRating?.invoke(ratings.usageQualityScore) ?: stringResource(R.string.not_available) } 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 index 7c33d99a6..a03b647e9 100644 --- a/app/src/main/java/foundation/e/apps/ui/utils/AppUserRatingFormatter.kt +++ b/app/src/main/java/foundation/e/apps/ui/utils/AppUserRatingFormatter.kt @@ -32,14 +32,21 @@ object AppUserRatingFormatter { return BigDecimal.valueOf(validRating).setScale(1, RoundingMode.HALF_UP).toDouble() } - fun format(rating: Double?, locale: Locale): String? { - val roundedRating = roundedNumericValue(rating) ?: return null - - return NumberFormat.getNumberInstance(locale).apply { + fun newFormatter(locale: Locale): NumberFormat = + NumberFormat.getNumberInstance(locale).apply { minimumFractionDigits = 0 maximumFractionDigits = 1 isGroupingUsed = false roundingMode = RoundingMode.HALF_UP - }.format(roundedRating) + } + + fun format(rating: Double?, formatter: NumberFormat): String? { + val roundedRating = roundedNumericValue(rating) ?: return null + + return formatter.format(roundedRating) + } + + fun format(rating: Double?, locale: Locale): String? { + return format(rating, newFormatter(locale)) } } 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 index d83fbbaac..6c325f2e7 100644 --- a/app/src/test/java/foundation/e/apps/ui/utils/AppUserRatingFormatterTest.kt +++ b/app/src/test/java/foundation/e/apps/ui/utils/AppUserRatingFormatterTest.kt @@ -75,4 +75,12 @@ class AppUserRatingFormatterTest { assertEquals("3,4", AppUserRatingFormatter.format(3.35, franceLocale)) assertEquals("3,6", AppUserRatingFormatter.format(3.6, franceLocale)) } + + @Test + fun format_reusesProvidedFormatterAcrossCalls() { + val formatter = AppUserRatingFormatter.newFormatter(usLocale) + + assertEquals("4.4", AppUserRatingFormatter.format(4.4, formatter)) + assertEquals("3.6", AppUserRatingFormatter.format(3.6, formatter)) + } } -- GitLab From 9d99eb89bb23fac2acc068462433a84d0a60b333 Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Tue, 31 Mar 2026 13:00:41 +0600 Subject: [PATCH 6/6] test: stabilize localized rating assertion in search results Use the activity configuration locale when building expected rating text in SearchResultsContentTest so the assertion matches the runtime locale on CI and developer devices. Also route the test through the common-apps search results path, which is the branch that actually applies localized rating formatting, instead of the open-source path that does not render localized ratings. --- .../compose/components/SearchResultsContentTest.kt | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) 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 074b47223..adc905d66 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 @@ -32,6 +32,7 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.core.os.ConfigurationCompat import androidx.paging.LoadState import androidx.paging.LoadStates import androidx.paging.PagingData @@ -138,14 +139,17 @@ class SearchResultsContentTest { fun applicationMapping_setsAuthorRatingAndPrimaryAction() { val notAvailable = composeRule.activity.getString(R.string.not_available) val openLabel = composeRule.activity.getString(R.string.open) - val locale = Locale.FRANCE + val locale = ConfigurationCompat.getLocales( + composeRule.activity.resources.configuration + ).get(0) ?: Locale.getDefault() val expectedRating = AppUserRatingFormatter.format(4.4, locale) ?: notAvailable val hiddenRating = AppUserRatingFormatter.format(4.9, locale) ?: notAvailable renderSearchResults( - tabs = listOf(SearchTabType.OPEN_SOURCE), - selectedTab = SearchTabType.OPEN_SOURCE, - fossPagingData = pagingData( + tabs = listOf(SearchTabType.COMMON_APPS), + selectedTab = SearchTabType.COMMON_APPS, + fossPagingData = PagingData.empty(), + playStorePagingData = pagingData( listOf( Application( name = "Rated App", -- GitLab