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..adc905d66ef3d4d2861d89e60268e57e7363c5d1 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 @@ -53,6 +54,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 foundation.e.apps.ui.utils.AppUserRatingFormatter import java.util.Locale @RunWith(AndroidJUnit4::class) @@ -137,13 +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 expectedRating = String.format(Locale.getDefault(), "%.1f", 4.4) - val unexpectedRating = String.format(Locale.getDefault(), "%.1f", 4.9) + 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", @@ -184,7 +190,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 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 f5f65d2aac4b1db08f452dd06833edfe68e93601..129b0d43fda72e83bc91995826414d30dbe3d329 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 @@ -76,6 +77,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,13 +360,16 @@ class ApplicationFragment : TimeoutFragment(R.layout.fragment_application) { private fun updateAppRating(it: Application) { binding.ratingsInclude.apply { - val formattedRating = applicationViewModel.handleRatingFormat(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 @@ -1076,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/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..4ac4c70608ef9b953693afbb19cc9b775998f0ba 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 @@ -53,10 +54,13 @@ 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 import timber.log.Timber +import java.text.NumberFormat +import java.util.Locale import javax.inject.Singleton import foundation.e.elib.R as eR @@ -72,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) @@ -211,10 +217,29 @@ class ApplicationListRVAdapter( appRating.isVisible = false return } - val formattedRating = mainActivityViewModel.handleRatingFormat(searchApp.ratings.usageQualityScore) + + val locale = ConfigurationCompat.getLocales(root.context.resources.configuration).get(0) + ?: Locale.getDefault() + 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/SearchResultListItem.kt b/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultListItem.kt index 31d77299d823a79a07ff4c208bda80ad8dd43fc6..81764f0bfeb03c1648cf938dc8f34e6f20f56b55 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, ) } 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..030ee01e1150cbae1c3075ccc161c30bfb569531 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 @@ -58,6 +60,7 @@ 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 @@ -293,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 @@ -347,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, @@ -535,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 = "", @@ -555,13 +576,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 -> formatRating?.invoke(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 0000000000000000000000000000000000000000..a03b647e916c5ff7543376158c2b93a44f596451 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/ui/utils/AppUserRatingFormatter.kt @@ -0,0 +1,52 @@ +/* + * 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.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 roundedNumericValue(rating: Double?): Double? { + val validRating = rating?.takeIf { it.isFinite() && it >= MIN_VALID_RATING } ?: return null + + return BigDecimal.valueOf(validRating).setScale(1, RoundingMode.HALF_UP).toDouble() + } + + fun newFormatter(locale: Locale): NumberFormat = + NumberFormat.getNumberInstance(locale).apply { + minimumFractionDigits = 0 + maximumFractionDigits = 1 + isGroupingUsed = false + roundingMode = RoundingMode.HALF_UP + } + + 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 new file mode 100644 index 0000000000000000000000000000000000000000..6c325f2e7148a203c7e56228fd7fac02aeb8ac81 --- /dev/null +++ b/app/src/test/java/foundation/e/apps/ui/utils/AppUserRatingFormatterTest.kt @@ -0,0 +1,86 @@ +/* + * 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_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_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)) + } + + @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)) + } +}