Loading app/build.gradle +1 −0 Original line number Diff line number Diff line Loading @@ -317,6 +317,7 @@ dependencies { // Coil and PhotoView implementation(libs.coil) implementation(libs.coil.compose) implementation(libs.photoview) // Protobuf and Gson Loading app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultListItem.kt 0 → 100644 +438 −0 Original line number Diff line number Diff line /* * 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 <https://www.gnu.org/licenses/>. * */ package foundation.e.apps.ui.compose.components import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable 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.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import coil.compose.rememberImagePainter import foundation.e.apps.R import foundation.e.apps.data.application.data.Application import foundation.e.apps.ui.compose.theme.AppTheme @Composable fun SearchResultListItem( application: Application, uiState: SearchResultListItemState, onItemClick: (Application) -> Unit, onPrimaryActionClick: (Application) -> Unit, onShowMoreClick: (Application) -> Unit, onPrivacyClick: (Application) -> Unit, modifier: Modifier = Modifier, ) { if (uiState.isPlaceholder) { PlaceholderRow(modifier = modifier) return } else { // fall through to render the normal row } val interactionSource = remember { MutableInteractionSource() } Row( modifier = modifier .fillMaxWidth() .clickable( interactionSource = interactionSource, indication = null, onClick = { onItemClick(application) }, ) .padding(horizontal = 12.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { AppIcon( imageUrl = uiState.iconUrl, contentDescription = application.name, placeholderPainterRes = uiState.placeholderResId, ) Column( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp), ) { Text( text = application.name, style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold), color = MaterialTheme.colorScheme.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Text( text = uiState.author, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.72f), maxLines = 1, overflow = TextOverflow.Ellipsis, ) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), ) { if (uiState.showRating) { RatingChip(ratingText = uiState.ratingText) } else { // keep layout predictable; hide rating when absent Spacer(modifier = Modifier.width(0.dp)) } if (uiState.showSourceTag) { SourceTag(text = uiState.sourceTag) } else { // design PNG omits source tag; kept togglable for legacy parity Spacer(modifier = Modifier.width(0.dp)) } } } PrimaryActionArea( uiState = uiState.primaryAction, onPrimaryClick = { onPrimaryActionClick(application) }, onShowMoreClick = { onShowMoreClick(application) }, privacyScore = uiState.privacyScore, showPrivacyScore = uiState.showPrivacyScore, isPrivacyLoading = uiState.isPrivacyLoading, onPrivacyClick = { onPrivacyClick(application) }, ) } } @Composable private fun AppIcon( imageUrl: String?, contentDescription: String, placeholderPainterRes: Int?, ) { val painter = rememberImagePainter( data = imageUrl, builder = { placeholderPainterRes?.let { placeholder(it) } placeholderPainterRes?.let { error(it) } placeholderPainterRes?.let { fallback(it) } }, ) Image( painter = painter, contentDescription = contentDescription, modifier = Modifier .size(64.dp) .clip(RoundedCornerShape(12.dp)) .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)), ) } @Composable private fun RatingChip(ratingText: String) { Row(verticalAlignment = Alignment.CenterVertically) { Image( 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, color = MaterialTheme.colorScheme.onBackground, ) } } @Composable private fun SourceTag(text: String) { Text( text = text, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSecondaryContainer, modifier = Modifier .background( color = MaterialTheme.colorScheme.secondaryContainer, shape = MaterialTheme.shapes.small, ) .padding(horizontal = 8.dp, vertical = 4.dp), maxLines = 1, overflow = TextOverflow.Ellipsis, ) } @Composable private fun PrivacyBadge( privacyScore: String, isVisible: Boolean, isLoading: Boolean, onClick: () -> Unit, ) { if (!isVisible) { return } else { // proceed to render the badge } Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.clickable(onClick = onClick), ) { Image( painter = painterResource(id = R.drawable.ic_lock), contentDescription = stringResource(id = R.string.privacy_score), modifier = Modifier.size(16.dp), ) Spacer(modifier = Modifier.width(4.dp)) if (isLoading) { CircularProgressIndicator( modifier = Modifier.size(16.dp), strokeWidth = 2.dp, ) } else { Text( text = privacyScore, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onBackground, ) } } } @Composable private fun PrimaryActionArea( uiState: PrimaryActionUiState, onPrimaryClick: () -> Unit, onShowMoreClick: () -> Unit, privacyScore: String, showPrivacyScore: Boolean, isPrivacyLoading: Boolean, onPrivacyClick: () -> Unit, ) { if (uiState.showMore) { Text( text = stringResource(id = R.string.show_more), style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.SemiBold), color = MaterialTheme.colorScheme.primary, modifier = Modifier.clickable(onClick = onShowMoreClick), ) return } else { // render the primary action button } val buttonContent: @Composable () -> Unit = { if (uiState.isInProgress) { val indicatorColor = if (uiState.isFilledStyle) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onPrimary CircularProgressIndicator( modifier = Modifier.size(16.dp), strokeWidth = 2.dp, color = indicatorColor, ) } else { val textColor = if (uiState.isFilledStyle) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface Text( text = uiState.label, maxLines = 1, overflow = TextOverflow.Clip, color = textColor, ) } } Column(horizontalAlignment = Alignment.End) { val containerColor = if (uiState.isFilledStyle) { MaterialTheme.colorScheme.primary } else { MaterialTheme.colorScheme.secondaryContainer } val contentColor = if (uiState.isFilledStyle) { MaterialTheme.colorScheme.onPrimary } else { MaterialTheme.colorScheme.onSecondaryContainer } Button( onClick = onPrimaryClick, enabled = uiState.enabled, modifier = Modifier.height(40.dp), shape = RoundedCornerShape(4.dp), colors = ButtonDefaults.buttonColors( containerColor = containerColor, contentColor = contentColor, disabledContainerColor = containerColor.copy(alpha = 0.38f), disabledContentColor = contentColor.copy(alpha = 0.38f), ), contentPadding = ButtonDefaults.ContentPadding, ) { buttonContent() } if (showPrivacyScore) { Spacer(modifier = Modifier.height(8.dp)) PrivacyBadge( privacyScore = privacyScore, isVisible = true, isLoading = isPrivacyLoading, onClick = onPrivacyClick, ) } } } @Composable private fun PlaceholderRow(modifier: Modifier = Modifier) { Box( modifier = modifier .fillMaxWidth() .padding(vertical = 16.dp), contentAlignment = Alignment.Center, ) { CircularProgressIndicator() } } data class SearchResultListItemState( val author: String, val ratingText: String, val showRating: Boolean, val sourceTag: String, val showSourceTag: Boolean, val privacyScore: String, val showPrivacyScore: Boolean, val isPrivacyLoading: Boolean, val primaryAction: PrimaryActionUiState, val iconUrl: String? = null, val placeholderResId: Int?, val isPlaceholder: Boolean = false, ) data class PrimaryActionUiState( val label: String, val enabled: Boolean, val isInProgress: Boolean, val isFilledStyle: Boolean, val showMore: Boolean = false, ) // --- Previews --- @Preview(showBackground = true) @Composable private fun SearchResultListItemPreviewInstall() { AppTheme(darkTheme = true) { Surface(color = MaterialTheme.colorScheme.background) { SearchResultListItem( application = sampleApp(name = "iMe: AI Messenger"), uiState = sampleState( rating = "4.4", privacy = "06/10", primary = PrimaryActionUiState( label = "Install", enabled = true, isInProgress = false, isFilledStyle = false, showMore = false, ), ), onItemClick = {}, onPrimaryActionClick = {}, onShowMoreClick = {}, onPrivacyClick = {}, ) } } } @Preview(showBackground = true) @Composable private fun SearchResultListItemPreviewOpen() { AppTheme(darkTheme = false) { Surface(color = MaterialTheme.colorScheme.background) { SearchResultListItem( application = sampleApp(name = "This is a very long app name"), uiState = sampleState( rating = "4.3", privacy = "10/10", primary = PrimaryActionUiState( label = "Open", enabled = true, isInProgress = false, isFilledStyle = true, showMore = false, ), ), onItemClick = {}, onPrimaryActionClick = {}, onShowMoreClick = {}, onPrivacyClick = {}, ) } } } private fun sampleApp(name: String) = Application(name = name) @Composable private fun sampleState( rating: String, privacy: String, primary: PrimaryActionUiState, ): SearchResultListItemState = SearchResultListItemState( author = "This is a very long author name which can take multiple lines", ratingText = rating, showRating = true, sourceTag = "Open-source", // PNG omits this; kept for legacy data showSourceTag = true, privacyScore = privacy, showPrivacyScore = true, isPrivacyLoading = false, primaryAction = primary, isPlaceholder = false, iconUrl = null, placeholderResId = null, ) app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultsContent.kt +120 −32 Original line number Diff line number Diff line Loading @@ -24,29 +24,35 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import foundation.e.apps.ui.compose.theme.AppTheme import foundation.e.apps.R import foundation.e.apps.data.application.data.Application import foundation.e.apps.data.enums.Source import foundation.e.apps.data.enums.Status import foundation.e.apps.ui.search.v2.SearchTabType import kotlinx.coroutines.launch import java.util.Locale @Composable fun SearchResultsContent( tabs: List<SearchTabType>, selectedTab: SearchTabType, resultsByTab: Map<SearchTabType, List<String>>, resultsByTab: Map<SearchTabType, List<Application>>, onTabSelect: (SearchTabType) -> Unit, modifier: Modifier = Modifier, onResultClick: (Application) -> Unit = {}, onPrimaryActionClick: (Application) -> Unit = {}, onShowMoreClick: (Application) -> Unit = {}, onPrivacyClick: (Application) -> Unit = {}, ) { if (tabs.isEmpty() || selectedTab !in tabs) { return Loading Loading @@ -100,6 +106,10 @@ fun SearchResultsContent( val items = resultsByTab[tab].orEmpty() SearchResultList( items = items, onItemClick = onResultClick, onPrimaryActionClick = onPrimaryActionClick, onShowMoreClick = onShowMoreClick, onPrivacyClick = onPrivacyClick, modifier = Modifier.fillMaxSize(), ) } Loading @@ -108,47 +118,125 @@ fun SearchResultsContent( @Composable private fun SearchResultList( items: List<String>, items: List<Application>, onItemClick: (Application) -> Unit, onPrimaryActionClick: (Application) -> Unit, onShowMoreClick: (Application) -> Unit, onPrivacyClick: (Application) -> Unit, modifier: Modifier = Modifier, ) { LazyColumn( modifier = modifier, verticalArrangement = Arrangement.spacedBy(12.dp), ) { items( itemsIndexed( items = items, key = { item -> item }, ) { item -> Text( text = item, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onBackground, key = { index, item -> item._id.takeIf { it.isNotBlank() } ?: item.package_name.takeIf { it.isNotBlank() } ?: "${item.name}-$index" }, ) { _, application -> SearchResultListItem( application = application, uiState = application.toSearchResultUiState(), onItemClick = onItemClick, onPrimaryActionClick = onPrimaryActionClick, onShowMoreClick = onShowMoreClick, onPrivacyClick = onPrivacyClick, modifier = Modifier.fillMaxWidth(), ) } } } @Preview(showBackground = true) @Composable private fun SearchResultsContentPreview() { AppTheme(darkTheme = true) { SearchResultsContent( tabs = listOf( SearchTabType.COMMON_APPS, SearchTabType.OPEN_SOURCE, SearchTabType.PWA, ), selectedTab = SearchTabType.OPEN_SOURCE, resultsByTab = mapOf( SearchTabType.COMMON_APPS to listOf( "Standard app 1 for Firefox", "Standard app 2 for Firefox" ), SearchTabType.OPEN_SOURCE to listOf("Open source app 1 for Firefox"), SearchTabType.PWA to listOf("Web app 1 for Firefox", "Web app 2 for Firefox"), private fun Application.toSearchResultUiState(): SearchResultListItemState { if (isPlaceHolder) { return SearchResultListItemState( author = "", ratingText = "", showRating = false, sourceTag = "", showSourceTag = false, privacyScore = "", showPrivacyScore = false, isPrivacyLoading = false, primaryAction = PrimaryActionUiState( label = "", enabled = false, isInProgress = false, isFilledStyle = true, ), onTabSelect = {}, iconUrl = null, placeholderResId = null, isPlaceholder = true, ) } 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) } val sourceTagText = source.toString() return SearchResultListItemState( author = author.ifBlank { package_name }, ratingText = ratingText, showRating = ratingText.isNotBlank(), sourceTag = sourceTagText, showSourceTag = false, privacyScore = "", showPrivacyScore = false, // Privacy scores are disabled on Search per functional spec. isPrivacyLoading = false, primaryAction = resolvePrimaryActionState(this), iconUrl = icon_image_path.takeIf { it.isNotBlank() }, placeholderResId = null, isPlaceholder = false, ) } @Composable private fun resolvePrimaryActionState(application: Application): PrimaryActionUiState { val label = when (application.status) { Status.INSTALLED -> stringResource(id = R.string.open) Status.UPDATABLE -> stringResource(id = R.string.update) Status.INSTALLING -> stringResource(id = R.string.installing) Status.DOWNLOADING, Status.DOWNLOADED, Status.QUEUED, Status.AWAITING -> stringResource(id = R.string.cancel) Status.INSTALLATION_ISSUE -> stringResource(id = R.string.retry) Status.PURCHASE_NEEDED -> application.price.ifBlank { stringResource(id = R.string.install) } Status.BLOCKED -> stringResource(id = R.string.install) Status.UNAVAILABLE -> { if (!application.isFree && !application.isPurchased) { application.price.ifBlank { stringResource(id = R.string.install) } } else { stringResource(id = R.string.install) } } } val isInProgress = when (application.status) { Status.INSTALLING, Status.DOWNLOADING, Status.DOWNLOADED, Status.QUEUED, Status.AWAITING -> true else -> false } val isEnabled = when (application.status) { Status.INSTALLING -> false else -> true } return PrimaryActionUiState( label = label, enabled = isEnabled, isInProgress = isInProgress, isFilledStyle = true, showMore = false, ) } app/src/main/java/foundation/e/apps/ui/compose/screens/SearchScreen.kt +8 −21 Original line number Diff line number Diff line Loading @@ -34,12 +34,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.tooling.preview.Preview import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.repeatOnLifecycle import foundation.e.apps.data.application.data.Application import foundation.e.apps.ui.compose.components.SearchResultsContent import foundation.e.apps.ui.compose.theme.AppTheme import foundation.e.apps.ui.search.v2.SearchTabType import foundation.e.apps.ui.search.v2.SearchUiState Loading @@ -53,6 +52,10 @@ fun SearchScreen( onSuggestionSelect: (String) -> Unit, onTabSelect: (SearchTabType) -> Unit, modifier: Modifier = Modifier, onResultClick: (Application) -> Unit = {}, onPrimaryActionClick: (Application) -> Unit = {}, onShowMoreClick: (Application) -> Unit = {}, onPrivacyClick: (Application) -> Unit = {}, ) { val focusManager = LocalFocusManager.current val keyboardController = LocalSoftwareKeyboardController.current Loading Loading @@ -127,6 +130,9 @@ fun SearchScreen( resultsByTab = uiState.resultsByTab, onTabSelect = onTabSelect, modifier = Modifier.fillMaxSize(), onResultClick = onResultClick, onPrimaryActionClick = onPrimaryActionClick, onShowMoreClick = onShowMoreClick, onPrivacyClick = onPrivacyClick, ) } Loading @@ -137,22 +143,3 @@ fun SearchScreen( } } } @Preview(showBackground = false) @Composable private fun SearchScreenPreview() { AppTheme(darkTheme = true) { SearchScreen( uiState = SearchUiState( query = "telegram", suggestions = listOf("telegram", "telegram messenger") ), onQueryChange = {}, onBackClick = {}, onClearQuery = {}, onSubmitSearch = {}, onSuggestionSelect = {}, onTabSelect = {}, ) } } app/src/main/java/foundation/e/apps/ui/compose/screens/SearchTopBar.kt +1 −1 Original line number Diff line number Diff line Loading @@ -213,7 +213,7 @@ private fun SearchTopBarPreview() { availableTabs = listOf( SearchTabType.COMMON_APPS, SearchTabType.OPEN_SOURCE, SearchTabType.WEB_APPS, SearchTabType.PWA, ), selectedTab = SearchTabType.OPEN_SOURCE, hasSubmittedSearch = false, Loading Loading
app/build.gradle +1 −0 Original line number Diff line number Diff line Loading @@ -317,6 +317,7 @@ dependencies { // Coil and PhotoView implementation(libs.coil) implementation(libs.coil.compose) implementation(libs.photoview) // Protobuf and Gson Loading
app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultListItem.kt 0 → 100644 +438 −0 Original line number Diff line number Diff line /* * 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 <https://www.gnu.org/licenses/>. * */ package foundation.e.apps.ui.compose.components import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable 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.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import coil.compose.rememberImagePainter import foundation.e.apps.R import foundation.e.apps.data.application.data.Application import foundation.e.apps.ui.compose.theme.AppTheme @Composable fun SearchResultListItem( application: Application, uiState: SearchResultListItemState, onItemClick: (Application) -> Unit, onPrimaryActionClick: (Application) -> Unit, onShowMoreClick: (Application) -> Unit, onPrivacyClick: (Application) -> Unit, modifier: Modifier = Modifier, ) { if (uiState.isPlaceholder) { PlaceholderRow(modifier = modifier) return } else { // fall through to render the normal row } val interactionSource = remember { MutableInteractionSource() } Row( modifier = modifier .fillMaxWidth() .clickable( interactionSource = interactionSource, indication = null, onClick = { onItemClick(application) }, ) .padding(horizontal = 12.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { AppIcon( imageUrl = uiState.iconUrl, contentDescription = application.name, placeholderPainterRes = uiState.placeholderResId, ) Column( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp), ) { Text( text = application.name, style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold), color = MaterialTheme.colorScheme.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Text( text = uiState.author, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.72f), maxLines = 1, overflow = TextOverflow.Ellipsis, ) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), ) { if (uiState.showRating) { RatingChip(ratingText = uiState.ratingText) } else { // keep layout predictable; hide rating when absent Spacer(modifier = Modifier.width(0.dp)) } if (uiState.showSourceTag) { SourceTag(text = uiState.sourceTag) } else { // design PNG omits source tag; kept togglable for legacy parity Spacer(modifier = Modifier.width(0.dp)) } } } PrimaryActionArea( uiState = uiState.primaryAction, onPrimaryClick = { onPrimaryActionClick(application) }, onShowMoreClick = { onShowMoreClick(application) }, privacyScore = uiState.privacyScore, showPrivacyScore = uiState.showPrivacyScore, isPrivacyLoading = uiState.isPrivacyLoading, onPrivacyClick = { onPrivacyClick(application) }, ) } } @Composable private fun AppIcon( imageUrl: String?, contentDescription: String, placeholderPainterRes: Int?, ) { val painter = rememberImagePainter( data = imageUrl, builder = { placeholderPainterRes?.let { placeholder(it) } placeholderPainterRes?.let { error(it) } placeholderPainterRes?.let { fallback(it) } }, ) Image( painter = painter, contentDescription = contentDescription, modifier = Modifier .size(64.dp) .clip(RoundedCornerShape(12.dp)) .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)), ) } @Composable private fun RatingChip(ratingText: String) { Row(verticalAlignment = Alignment.CenterVertically) { Image( 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, color = MaterialTheme.colorScheme.onBackground, ) } } @Composable private fun SourceTag(text: String) { Text( text = text, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSecondaryContainer, modifier = Modifier .background( color = MaterialTheme.colorScheme.secondaryContainer, shape = MaterialTheme.shapes.small, ) .padding(horizontal = 8.dp, vertical = 4.dp), maxLines = 1, overflow = TextOverflow.Ellipsis, ) } @Composable private fun PrivacyBadge( privacyScore: String, isVisible: Boolean, isLoading: Boolean, onClick: () -> Unit, ) { if (!isVisible) { return } else { // proceed to render the badge } Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.clickable(onClick = onClick), ) { Image( painter = painterResource(id = R.drawable.ic_lock), contentDescription = stringResource(id = R.string.privacy_score), modifier = Modifier.size(16.dp), ) Spacer(modifier = Modifier.width(4.dp)) if (isLoading) { CircularProgressIndicator( modifier = Modifier.size(16.dp), strokeWidth = 2.dp, ) } else { Text( text = privacyScore, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onBackground, ) } } } @Composable private fun PrimaryActionArea( uiState: PrimaryActionUiState, onPrimaryClick: () -> Unit, onShowMoreClick: () -> Unit, privacyScore: String, showPrivacyScore: Boolean, isPrivacyLoading: Boolean, onPrivacyClick: () -> Unit, ) { if (uiState.showMore) { Text( text = stringResource(id = R.string.show_more), style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.SemiBold), color = MaterialTheme.colorScheme.primary, modifier = Modifier.clickable(onClick = onShowMoreClick), ) return } else { // render the primary action button } val buttonContent: @Composable () -> Unit = { if (uiState.isInProgress) { val indicatorColor = if (uiState.isFilledStyle) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onPrimary CircularProgressIndicator( modifier = Modifier.size(16.dp), strokeWidth = 2.dp, color = indicatorColor, ) } else { val textColor = if (uiState.isFilledStyle) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface Text( text = uiState.label, maxLines = 1, overflow = TextOverflow.Clip, color = textColor, ) } } Column(horizontalAlignment = Alignment.End) { val containerColor = if (uiState.isFilledStyle) { MaterialTheme.colorScheme.primary } else { MaterialTheme.colorScheme.secondaryContainer } val contentColor = if (uiState.isFilledStyle) { MaterialTheme.colorScheme.onPrimary } else { MaterialTheme.colorScheme.onSecondaryContainer } Button( onClick = onPrimaryClick, enabled = uiState.enabled, modifier = Modifier.height(40.dp), shape = RoundedCornerShape(4.dp), colors = ButtonDefaults.buttonColors( containerColor = containerColor, contentColor = contentColor, disabledContainerColor = containerColor.copy(alpha = 0.38f), disabledContentColor = contentColor.copy(alpha = 0.38f), ), contentPadding = ButtonDefaults.ContentPadding, ) { buttonContent() } if (showPrivacyScore) { Spacer(modifier = Modifier.height(8.dp)) PrivacyBadge( privacyScore = privacyScore, isVisible = true, isLoading = isPrivacyLoading, onClick = onPrivacyClick, ) } } } @Composable private fun PlaceholderRow(modifier: Modifier = Modifier) { Box( modifier = modifier .fillMaxWidth() .padding(vertical = 16.dp), contentAlignment = Alignment.Center, ) { CircularProgressIndicator() } } data class SearchResultListItemState( val author: String, val ratingText: String, val showRating: Boolean, val sourceTag: String, val showSourceTag: Boolean, val privacyScore: String, val showPrivacyScore: Boolean, val isPrivacyLoading: Boolean, val primaryAction: PrimaryActionUiState, val iconUrl: String? = null, val placeholderResId: Int?, val isPlaceholder: Boolean = false, ) data class PrimaryActionUiState( val label: String, val enabled: Boolean, val isInProgress: Boolean, val isFilledStyle: Boolean, val showMore: Boolean = false, ) // --- Previews --- @Preview(showBackground = true) @Composable private fun SearchResultListItemPreviewInstall() { AppTheme(darkTheme = true) { Surface(color = MaterialTheme.colorScheme.background) { SearchResultListItem( application = sampleApp(name = "iMe: AI Messenger"), uiState = sampleState( rating = "4.4", privacy = "06/10", primary = PrimaryActionUiState( label = "Install", enabled = true, isInProgress = false, isFilledStyle = false, showMore = false, ), ), onItemClick = {}, onPrimaryActionClick = {}, onShowMoreClick = {}, onPrivacyClick = {}, ) } } } @Preview(showBackground = true) @Composable private fun SearchResultListItemPreviewOpen() { AppTheme(darkTheme = false) { Surface(color = MaterialTheme.colorScheme.background) { SearchResultListItem( application = sampleApp(name = "This is a very long app name"), uiState = sampleState( rating = "4.3", privacy = "10/10", primary = PrimaryActionUiState( label = "Open", enabled = true, isInProgress = false, isFilledStyle = true, showMore = false, ), ), onItemClick = {}, onPrimaryActionClick = {}, onShowMoreClick = {}, onPrivacyClick = {}, ) } } } private fun sampleApp(name: String) = Application(name = name) @Composable private fun sampleState( rating: String, privacy: String, primary: PrimaryActionUiState, ): SearchResultListItemState = SearchResultListItemState( author = "This is a very long author name which can take multiple lines", ratingText = rating, showRating = true, sourceTag = "Open-source", // PNG omits this; kept for legacy data showSourceTag = true, privacyScore = privacy, showPrivacyScore = true, isPrivacyLoading = false, primaryAction = primary, isPlaceholder = false, iconUrl = null, placeholderResId = null, )
app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultsContent.kt +120 −32 Original line number Diff line number Diff line Loading @@ -24,29 +24,35 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import foundation.e.apps.ui.compose.theme.AppTheme import foundation.e.apps.R import foundation.e.apps.data.application.data.Application import foundation.e.apps.data.enums.Source import foundation.e.apps.data.enums.Status import foundation.e.apps.ui.search.v2.SearchTabType import kotlinx.coroutines.launch import java.util.Locale @Composable fun SearchResultsContent( tabs: List<SearchTabType>, selectedTab: SearchTabType, resultsByTab: Map<SearchTabType, List<String>>, resultsByTab: Map<SearchTabType, List<Application>>, onTabSelect: (SearchTabType) -> Unit, modifier: Modifier = Modifier, onResultClick: (Application) -> Unit = {}, onPrimaryActionClick: (Application) -> Unit = {}, onShowMoreClick: (Application) -> Unit = {}, onPrivacyClick: (Application) -> Unit = {}, ) { if (tabs.isEmpty() || selectedTab !in tabs) { return Loading Loading @@ -100,6 +106,10 @@ fun SearchResultsContent( val items = resultsByTab[tab].orEmpty() SearchResultList( items = items, onItemClick = onResultClick, onPrimaryActionClick = onPrimaryActionClick, onShowMoreClick = onShowMoreClick, onPrivacyClick = onPrivacyClick, modifier = Modifier.fillMaxSize(), ) } Loading @@ -108,47 +118,125 @@ fun SearchResultsContent( @Composable private fun SearchResultList( items: List<String>, items: List<Application>, onItemClick: (Application) -> Unit, onPrimaryActionClick: (Application) -> Unit, onShowMoreClick: (Application) -> Unit, onPrivacyClick: (Application) -> Unit, modifier: Modifier = Modifier, ) { LazyColumn( modifier = modifier, verticalArrangement = Arrangement.spacedBy(12.dp), ) { items( itemsIndexed( items = items, key = { item -> item }, ) { item -> Text( text = item, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onBackground, key = { index, item -> item._id.takeIf { it.isNotBlank() } ?: item.package_name.takeIf { it.isNotBlank() } ?: "${item.name}-$index" }, ) { _, application -> SearchResultListItem( application = application, uiState = application.toSearchResultUiState(), onItemClick = onItemClick, onPrimaryActionClick = onPrimaryActionClick, onShowMoreClick = onShowMoreClick, onPrivacyClick = onPrivacyClick, modifier = Modifier.fillMaxWidth(), ) } } } @Preview(showBackground = true) @Composable private fun SearchResultsContentPreview() { AppTheme(darkTheme = true) { SearchResultsContent( tabs = listOf( SearchTabType.COMMON_APPS, SearchTabType.OPEN_SOURCE, SearchTabType.PWA, ), selectedTab = SearchTabType.OPEN_SOURCE, resultsByTab = mapOf( SearchTabType.COMMON_APPS to listOf( "Standard app 1 for Firefox", "Standard app 2 for Firefox" ), SearchTabType.OPEN_SOURCE to listOf("Open source app 1 for Firefox"), SearchTabType.PWA to listOf("Web app 1 for Firefox", "Web app 2 for Firefox"), private fun Application.toSearchResultUiState(): SearchResultListItemState { if (isPlaceHolder) { return SearchResultListItemState( author = "", ratingText = "", showRating = false, sourceTag = "", showSourceTag = false, privacyScore = "", showPrivacyScore = false, isPrivacyLoading = false, primaryAction = PrimaryActionUiState( label = "", enabled = false, isInProgress = false, isFilledStyle = true, ), onTabSelect = {}, iconUrl = null, placeholderResId = null, isPlaceholder = true, ) } 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) } val sourceTagText = source.toString() return SearchResultListItemState( author = author.ifBlank { package_name }, ratingText = ratingText, showRating = ratingText.isNotBlank(), sourceTag = sourceTagText, showSourceTag = false, privacyScore = "", showPrivacyScore = false, // Privacy scores are disabled on Search per functional spec. isPrivacyLoading = false, primaryAction = resolvePrimaryActionState(this), iconUrl = icon_image_path.takeIf { it.isNotBlank() }, placeholderResId = null, isPlaceholder = false, ) } @Composable private fun resolvePrimaryActionState(application: Application): PrimaryActionUiState { val label = when (application.status) { Status.INSTALLED -> stringResource(id = R.string.open) Status.UPDATABLE -> stringResource(id = R.string.update) Status.INSTALLING -> stringResource(id = R.string.installing) Status.DOWNLOADING, Status.DOWNLOADED, Status.QUEUED, Status.AWAITING -> stringResource(id = R.string.cancel) Status.INSTALLATION_ISSUE -> stringResource(id = R.string.retry) Status.PURCHASE_NEEDED -> application.price.ifBlank { stringResource(id = R.string.install) } Status.BLOCKED -> stringResource(id = R.string.install) Status.UNAVAILABLE -> { if (!application.isFree && !application.isPurchased) { application.price.ifBlank { stringResource(id = R.string.install) } } else { stringResource(id = R.string.install) } } } val isInProgress = when (application.status) { Status.INSTALLING, Status.DOWNLOADING, Status.DOWNLOADED, Status.QUEUED, Status.AWAITING -> true else -> false } val isEnabled = when (application.status) { Status.INSTALLING -> false else -> true } return PrimaryActionUiState( label = label, enabled = isEnabled, isInProgress = isInProgress, isFilledStyle = true, showMore = false, ) }
app/src/main/java/foundation/e/apps/ui/compose/screens/SearchScreen.kt +8 −21 Original line number Diff line number Diff line Loading @@ -34,12 +34,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.tooling.preview.Preview import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.repeatOnLifecycle import foundation.e.apps.data.application.data.Application import foundation.e.apps.ui.compose.components.SearchResultsContent import foundation.e.apps.ui.compose.theme.AppTheme import foundation.e.apps.ui.search.v2.SearchTabType import foundation.e.apps.ui.search.v2.SearchUiState Loading @@ -53,6 +52,10 @@ fun SearchScreen( onSuggestionSelect: (String) -> Unit, onTabSelect: (SearchTabType) -> Unit, modifier: Modifier = Modifier, onResultClick: (Application) -> Unit = {}, onPrimaryActionClick: (Application) -> Unit = {}, onShowMoreClick: (Application) -> Unit = {}, onPrivacyClick: (Application) -> Unit = {}, ) { val focusManager = LocalFocusManager.current val keyboardController = LocalSoftwareKeyboardController.current Loading Loading @@ -127,6 +130,9 @@ fun SearchScreen( resultsByTab = uiState.resultsByTab, onTabSelect = onTabSelect, modifier = Modifier.fillMaxSize(), onResultClick = onResultClick, onPrimaryActionClick = onPrimaryActionClick, onShowMoreClick = onShowMoreClick, onPrivacyClick = onPrivacyClick, ) } Loading @@ -137,22 +143,3 @@ fun SearchScreen( } } } @Preview(showBackground = false) @Composable private fun SearchScreenPreview() { AppTheme(darkTheme = true) { SearchScreen( uiState = SearchUiState( query = "telegram", suggestions = listOf("telegram", "telegram messenger") ), onQueryChange = {}, onBackClick = {}, onClearQuery = {}, onSubmitSearch = {}, onSuggestionSelect = {}, onTabSelect = {}, ) } }
app/src/main/java/foundation/e/apps/ui/compose/screens/SearchTopBar.kt +1 −1 Original line number Diff line number Diff line Loading @@ -213,7 +213,7 @@ private fun SearchTopBarPreview() { availableTabs = listOf( SearchTabType.COMMON_APPS, SearchTabType.OPEN_SOURCE, SearchTabType.WEB_APPS, SearchTabType.PWA, ), selectedTab = SearchTabType.OPEN_SOURCE, hasSubmittedSearch = false, Loading