Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Verified Commit cf93370d authored by Fahim M. Choudhury's avatar Fahim M. Choudhury
Browse files

feat: implement search results item composable

parent d29ec26d
Loading
Loading
Loading
Loading
+1 −0
Original line number Diff line number Diff line
@@ -315,6 +315,7 @@ dependencies {

    // Coil and PhotoView
    implementation(libs.coil)
    implementation(libs.coil.compose)
    implementation(libs.photoview)

    // Protobuf and Gson
+442 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 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.material.icons.Icons
import androidx.compose.material.icons.rounded.Star
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.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
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(
            imageVector = Icons.Rounded.Star,
            contentDescription = stringResource(id = R.string.rating),
            modifier = Modifier.size(18.dp),
            colorFilter = ColorFilter.tint(Color(0xFFFBC02D)),
        )
        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,
    )
+2 −0
Original line number Diff line number Diff line
@@ -5,6 +5,7 @@ androidGradlePlugin = "8.9.3"
appcompat = "1.7.0"
bcpgJdk15on = "1.60"
coil = "1.4.0"
coilCompose = "1.4.0"
composeBom = "2025.12.01"
constraintlayout = "2.2.0"
core = "1.6.1"
@@ -62,6 +63,7 @@ activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "activ
appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" }
bcpg-jdk15on = { module = "org.bouncycastle:bcpg-jdk15on", version.ref = "bcpgJdk15on" }
coil = { module = "io.coil-kt:coil", version.ref = "coil" }
coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coilCompose" }
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
compose-material3 = { module = "androidx.compose.material3:material3" }
compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }