diff --git a/app/src/androidTest/java/org/mozilla/tryfox/ui/composables/ArchiveGroupCardHeaderLayoutTest.kt b/app/src/androidTest/java/org/mozilla/tryfox/ui/composables/ArchiveGroupCardHeaderLayoutTest.kt deleted file mode 100644 index 7e72b01..0000000 --- a/app/src/androidTest/java/org/mozilla/tryfox/ui/composables/ArchiveGroupCardHeaderLayoutTest.kt +++ /dev/null @@ -1,273 +0,0 @@ -package org.mozilla.tryfox.ui.composables - -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertIsSelected -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onAllNodesWithTag -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.test.ext.junit.runners.AndroidJUnit4 -import kotlinx.datetime.LocalDate -import org.junit.Assert.assertTrue -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.mozilla.tryfox.data.DownloadState -import org.mozilla.tryfox.ui.models.AbiUiModel -import org.mozilla.tryfox.ui.models.ApkUiModel -import org.mozilla.tryfox.ui.theme.TryFoxTheme -import org.mozilla.tryfox.util.FENIX_BETA -import org.mozilla.tryfox.util.FENIX_RELEASE -import org.mozilla.tryfox.util.FOCUS -import org.mozilla.tryfox.util.FOCUS_BETA -import org.mozilla.tryfox.util.FOCUS_RELEASE -import java.io.File - -@RunWith(AndroidJUnit4::class) -class ArchiveGroupCardHeaderLayoutTest { - - @get:Rule - val composeTestRule = createComposeRule() - - @Test - fun nightlyCard_placesDateSelectorBelowHeaderRow() { - composeTestRule.setContent { - TryFoxTheme(dynamicColor = false) { - ArchiveGroupCard( - apks = listOf( - createApkUiModel( - appName = FOCUS, - version = "126.0a1", - date = "2026-03-26 10:00", - ), - ), - onDownloadClick = {}, - onInstallClick = {}, - onOpenAppClick = {}, - onUninstallClick = {}, - appState = null, - onDateSelected = {}, - userPickedDate = LocalDate(2026, 3, 25), - selectedReleaseVersion = null, - availableReleaseVersions = emptyList(), - appName = FOCUS, - errorMessage = null, - isLoading = false, - dateValidator = { true }, - onClearDate = {}, - onReleaseVersionSelected = {}, - ) - } - } - - val headerBounds = composeTestRule - .onNodeWithTag("app_header_row_focus", useUnmergedTree = true) - .fetchSemanticsNode().boundsInRoot - val dateChipBounds = composeTestRule - .onNodeWithTag("app_date_chip_focus", useUnmergedTree = true) - .assertIsDisplayed() - .fetchSemanticsNode().boundsInRoot - - assertTrue( - "Expected date chip to appear below the header row, but top=${dateChipBounds.top} bottom=${headerBounds.bottom}", - dateChipBounds.top > headerBounds.bottom, - ) - } - - @Test - fun releaseCard_showsVersionChipAndNoDateChip() { - composeTestRule.setContent { - TryFoxTheme(dynamicColor = false) { - ArchiveGroupCard( - apks = listOf( - createApkUiModel( - appName = FOCUS_RELEASE, - version = "147.0.1", - date = "", - ), - ), - onDownloadClick = {}, - onInstallClick = {}, - onOpenAppClick = {}, - onUninstallClick = {}, - appState = null, - onDateSelected = {}, - userPickedDate = null, - selectedReleaseVersion = "147.0.1", - availableReleaseVersions = listOf("147.0.1", "147.0", "146.0.1"), - appName = FOCUS_RELEASE, - errorMessage = null, - isLoading = false, - dateValidator = { true }, - onClearDate = {}, - onReleaseVersionSelected = {}, - ) - } - } - - composeTestRule - .onNodeWithTag("release_version_chip_focus-release", useUnmergedTree = true) - .assertIsDisplayed() - - assertTrue( - "Release card should not render a date chip below the header", - composeTestRule - .onAllNodesWithTag("app_date_chip_focus-release", useUnmergedTree = true) - .fetchSemanticsNodes() - .isEmpty(), - ) - } - - @Test - fun releaseVersionSelector_opensAtSelectedMajorAndConfirmsChosenVariant() { - var confirmedVersion: String? = null - composeTestRule.setContent { - TryFoxTheme(dynamicColor = false) { - ArchiveGroupCard( - apks = listOf(createApkUiModel(FOCUS_RELEASE, "151.0.1", "")), - onDownloadClick = {}, onInstallClick = {}, onOpenAppClick = {}, onUninstallClick = {}, - appState = null, onDateSelected = {}, userPickedDate = null, - selectedReleaseVersion = "151.0.1", - availableReleaseVersions = listOf("151.0.1", "151.0.0", "150.0.1"), - appName = FOCUS_RELEASE, errorMessage = null, isLoading = false, - dateValidator = { true }, onClearDate = {}, - onReleaseVersionSelected = { confirmedVersion = it }, - ) - } - } - - composeTestRule.onNodeWithTag("release_version_chip_focus-release", useUnmergedTree = true).performClick() - composeTestRule.onNodeWithTag("release_version_selector_sheet_focus-release", useUnmergedTree = true).assertIsDisplayed() - composeTestRule.onNodeWithText("Version").assertIsDisplayed() - composeTestRule.onNodeWithText("Builds:").assertIsDisplayed() - composeTestRule.onNodeWithTag("release_version_variant_151_0_1", useUnmergedTree = true).assertIsSelected() - - composeTestRule.onNodeWithTag("release_version_major_picker_focus-release", useUnmergedTree = true).assertIsDisplayed() - composeTestRule.onNodeWithTag("release_version_variant_151_0_0", useUnmergedTree = true).performClick() - - assertTrue(confirmedVersion == "151.0.0") - assertTrue( - composeTestRule - .onAllNodesWithTag("release_version_selector_sheet_focus-release", useUnmergedTree = true) - .fetchSemanticsNodes() - .isEmpty(), - ) - } - - @Test - fun betaVersionSelector_filtersVariantsAndCancelKeepsSelection() { - var confirmedVersion: String? = null - composeTestRule.setContent { - TryFoxTheme(dynamicColor = false) { - ArchiveGroupCard( - apks = listOf(createApkUiModel(FOCUS_BETA, "151.0b2", "")), - onDownloadClick = {}, onInstallClick = {}, onOpenAppClick = {}, onUninstallClick = {}, - appState = null, onDateSelected = {}, userPickedDate = null, - selectedReleaseVersion = "151.0b2", - availableReleaseVersions = listOf("151.0b2", "151.0b1", "150.0b3"), - appName = FOCUS_BETA, errorMessage = null, isLoading = false, - dateValidator = { true }, onClearDate = {}, - onReleaseVersionSelected = { confirmedVersion = it }, - ) - } - } - - composeTestRule.onNodeWithTag("release_version_chip_focus-beta", useUnmergedTree = true).performClick() - composeTestRule.onNodeWithTag("release_version_variant_151_0b2", useUnmergedTree = true).assertIsSelected() - assertTrue( - composeTestRule - .onAllNodesWithTag("release_version_variant_150_0b3", useUnmergedTree = true) - .fetchSemanticsNodes() - .isEmpty(), - ) - - composeTestRule.onNodeWithTag("release_version_major_picker_focus-beta", useUnmergedTree = true).assertIsDisplayed() - composeTestRule.onNodeWithText("Cancel").performClick() - - assertTrue(confirmedVersion == null) - } - - @Test - fun versionSelector_supportsTheHighestMajorProvidedByTheArchive() { - composeTestRule.setContent { - TryFoxTheme(dynamicColor = false) { - ArchiveGroupCard( - apks = listOf(createApkUiModel(FOCUS_RELEASE, "155.0.1", "")), - onDownloadClick = {}, onInstallClick = {}, onOpenAppClick = {}, onUninstallClick = {}, - appState = null, onDateSelected = {}, userPickedDate = null, - selectedReleaseVersion = "155.0.1", - availableReleaseVersions = listOf("155.0.1", "154.0.2"), - appName = FOCUS_RELEASE, errorMessage = null, isLoading = false, - dateValidator = { true }, onClearDate = {}, onReleaseVersionSelected = {}, - ) - } - } - - composeTestRule.onNodeWithTag("release_version_chip_focus-release", useUnmergedTree = true).performClick() - composeTestRule.onNodeWithTag("release_version_variant_155_0_1", useUnmergedTree = true).assertIsDisplayed() - } - - @Test - fun fenixReleaseSelector_showsCandidateVariantsAndConfirmsTheSelectedRc() { - var confirmedVersion: String? = null - composeTestRule.setContent { - TryFoxTheme(dynamicColor = false) { - ArchiveGroupCard( - apks = listOf(createApkUiModel(FENIX_RELEASE, "153.0.4", "")), - onDownloadClick = {}, onInstallClick = {}, onOpenAppClick = {}, onUninstallClick = {}, - appState = null, onDateSelected = {}, userPickedDate = null, - selectedReleaseVersion = "153.0.4", - availableReleaseVersions = listOf("153.0.4", "153.0.4-RC2", "153.0.4-RC1"), - appName = FENIX_RELEASE, errorMessage = null, isLoading = false, - dateValidator = { true }, onClearDate = {}, - onReleaseVersionSelected = { confirmedVersion = it }, - ) - } - } - - composeTestRule.onNodeWithTag("release_version_chip_fenix-release", useUnmergedTree = true).performClick() - composeTestRule.onNodeWithTag("release_version_variant_153_0_4-RC2", useUnmergedTree = true).performClick() - - assertTrue(confirmedVersion == "153.0.4-RC2") - } - - @Test - fun fenixBetaSelector_showsCandidateVariants() { - composeTestRule.setContent { - TryFoxTheme(dynamicColor = false) { - ArchiveGroupCard( - apks = listOf(createApkUiModel(FENIX_BETA, "153.0b5", "")), - onDownloadClick = {}, onInstallClick = {}, onOpenAppClick = {}, onUninstallClick = {}, - appState = null, onDateSelected = {}, userPickedDate = null, - selectedReleaseVersion = "153.0b5", - availableReleaseVersions = listOf("153.0b5", "153.0b5-RC2", "153.0b5-RC1"), - appName = FENIX_BETA, errorMessage = null, isLoading = false, - dateValidator = { true }, onClearDate = {}, onReleaseVersionSelected = {}, - ) - } - } - - composeTestRule.onNodeWithTag("release_version_chip_fenix-beta", useUnmergedTree = true).performClick() - composeTestRule.onNodeWithTag("release_version_variant_153_0b5-RC2", useUnmergedTree = true).assertIsDisplayed() - } - - private fun createApkUiModel( - appName: String, - version: String, - date: String, - ): ApkUiModel { - return ApkUiModel( - originalString = "$appName-$version", - date = date, - appName = appName, - version = version, - abi = AbiUiModel(name = "arm64-v8a", isSupported = true), - url = "https://example.invalid/$appName/$version.apk", - fileName = "$appName-$version.apk", - downloadState = DownloadState.NotDownloaded, - uniqueKey = "$appName/$version", - apkDir = File("/tmp"), - ) - } -} diff --git a/app/src/androidTest/java/org/mozilla/tryfox/ui/composables/VersionSelectorTest.kt b/app/src/androidTest/java/org/mozilla/tryfox/ui/composables/VersionSelectorTest.kt new file mode 100644 index 0000000..a443dd6 --- /dev/null +++ b/app/src/androidTest/java/org/mozilla/tryfox/ui/composables/VersionSelectorTest.kt @@ -0,0 +1,141 @@ +package org.mozilla.tryfox.ui.composables + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.tryfox.ui.theme.TryFoxTheme +import org.mozilla.tryfox.util.FENIX_BETA +import org.mozilla.tryfox.util.FENIX_RELEASE +import org.mozilla.tryfox.util.FOCUS_BETA +import org.mozilla.tryfox.util.FOCUS_RELEASE + +@RunWith(AndroidJUnit4::class) +class VersionSelectorTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun releaseVersionSelector_opensAtSelectedMajorAndConfirmsChosenVariant() { + var confirmedVersion: String? = null + composeTestRule.setContent { + TryFoxTheme(dynamicColor = false) { + VersionSelector( + appName = FOCUS_RELEASE, + selectedReleaseVersion = "151.0.1", + availableReleaseVersions = listOf("151.0.1", "151.0.0", "150.0.1"), + onReleaseVersionSelected = { confirmedVersion = it }, + ) + } + } + + composeTestRule.onNodeWithTag("release_version_chip_focus-release", useUnmergedTree = true).performClick() + composeTestRule.onNodeWithTag("release_version_selector_sheet_focus-release", useUnmergedTree = true).assertIsDisplayed() + composeTestRule.onNodeWithText("Version").assertIsDisplayed() + composeTestRule.onNodeWithText("Builds:").assertIsDisplayed() + composeTestRule.onNodeWithTag("release_version_variant_151_0_1", useUnmergedTree = true).assertIsSelected() + + composeTestRule.onNodeWithTag("release_version_major_picker_focus-release", useUnmergedTree = true).assertIsDisplayed() + composeTestRule.onNodeWithTag("release_version_variant_151_0_0", useUnmergedTree = true).performClick() + + assertTrue(confirmedVersion == "151.0.0") + assertTrue( + composeTestRule + .onAllNodesWithTag("release_version_selector_sheet_focus-release", useUnmergedTree = true) + .fetchSemanticsNodes() + .isEmpty(), + ) + } + + @Test + fun betaVersionSelector_filtersVariantsAndCancelKeepsSelection() { + var confirmedVersion: String? = null + composeTestRule.setContent { + TryFoxTheme(dynamicColor = false) { + VersionSelector( + appName = FOCUS_BETA, + selectedReleaseVersion = "151.0b2", + availableReleaseVersions = listOf("151.0b2", "151.0b1", "150.0b3"), + onReleaseVersionSelected = { confirmedVersion = it }, + ) + } + } + + composeTestRule.onNodeWithTag("release_version_chip_focus-beta", useUnmergedTree = true).performClick() + composeTestRule.onNodeWithTag("release_version_variant_151_0b2", useUnmergedTree = true).assertIsSelected() + assertTrue( + composeTestRule + .onAllNodesWithTag("release_version_variant_150_0b3", useUnmergedTree = true) + .fetchSemanticsNodes() + .isEmpty(), + ) + + composeTestRule.onNodeWithTag("release_version_major_picker_focus-beta", useUnmergedTree = true).assertIsDisplayed() + composeTestRule.onNodeWithText("Cancel").performClick() + + assertTrue(confirmedVersion == null) + } + + @Test + fun versionSelector_supportsTheHighestMajorProvidedByTheArchive() { + composeTestRule.setContent { + TryFoxTheme(dynamicColor = false) { + VersionSelector( + appName = FOCUS_RELEASE, + selectedReleaseVersion = "155.0.1", + availableReleaseVersions = listOf("155.0.1", "154.0.2"), + onReleaseVersionSelected = {}, + ) + } + } + + composeTestRule.onNodeWithTag("release_version_chip_focus-release", useUnmergedTree = true).performClick() + composeTestRule.onNodeWithTag("release_version_variant_155_0_1", useUnmergedTree = true).assertIsDisplayed() + } + + @Test + fun fenixReleaseSelector_showsCandidateVariantsAndConfirmsTheSelectedRc() { + var confirmedVersion: String? = null + composeTestRule.setContent { + TryFoxTheme(dynamicColor = false) { + VersionSelector( + appName = FENIX_RELEASE, + selectedReleaseVersion = "153.0.4", + availableReleaseVersions = listOf("153.0.4", "153.0.4-RC2", "153.0.4-RC1"), + onReleaseVersionSelected = { confirmedVersion = it }, + ) + } + } + + composeTestRule.onNodeWithTag("release_version_chip_fenix-release", useUnmergedTree = true).performClick() + composeTestRule.onNodeWithTag("release_version_variant_153_0_4-RC2", useUnmergedTree = true).performClick() + + assertTrue(confirmedVersion == "153.0.4-RC2") + } + + @Test + fun fenixBetaSelector_showsCandidateVariants() { + composeTestRule.setContent { + TryFoxTheme(dynamicColor = false) { + VersionSelector( + appName = FENIX_BETA, + selectedReleaseVersion = "153.0b5", + availableReleaseVersions = listOf("153.0b5", "153.0b5-RC2", "153.0b5-RC1"), + onReleaseVersionSelected = {}, + ) + } + } + + composeTestRule.onNodeWithTag("release_version_chip_fenix-beta", useUnmergedTree = true).performClick() + composeTestRule.onNodeWithTag("release_version_variant_153_0b5-RC2", useUnmergedTree = true).assertIsDisplayed() + } +} diff --git a/app/src/main/java/org/mozilla/tryfox/ui/composables/ArchiveGroupCard.kt b/app/src/main/java/org/mozilla/tryfox/ui/composables/ArchiveGroupCard.kt deleted file mode 100644 index fca2bc8..0000000 --- a/app/src/main/java/org/mozilla/tryfox/ui/composables/ArchiveGroupCard.kt +++ /dev/null @@ -1,767 +0,0 @@ -package org.mozilla.tryfox.ui.composables - -import android.os.Build -import android.text.Editable -import android.text.InputType -import android.text.TextWatcher -import android.view.ViewGroup -import android.widget.EditText -import android.widget.NumberPicker -import androidx.compose.foundation.clickable -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.fillMaxHeight -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.wrapContentWidth -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowDropDown -import androidx.compose.material.icons.filled.ArrowDropUp -import androidx.compose.material.icons.filled.Clear -import androidx.compose.material.icons.filled.Warning -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.AssistChip -import androidx.compose.material3.AssistChipDefaults -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DatePicker -import androidx.compose.material3.DatePickerDialog -import androidx.compose.material3.ElevatedCard -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.RadioButton -import androidx.compose.material3.SegmentedButton -import androidx.compose.material3.SegmentedButtonDefaults -import androidx.compose.material3.SelectableDates -import androidx.compose.material3.SingleChoiceSegmentedButtonRow -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.rememberDatePickerState -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clipToBounds -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.selected -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import kotlinx.datetime.Clock -import kotlinx.datetime.Instant -import kotlinx.datetime.LocalDate -import kotlinx.datetime.TimeZone -import kotlinx.datetime.atStartOfDayIn -import kotlinx.datetime.toLocalDateTime -import org.mozilla.tryfox.R -import org.mozilla.tryfox.install.InstallState -import org.mozilla.tryfox.model.AppState -import org.mozilla.tryfox.ui.models.ApkUiModel -import org.mozilla.tryfox.ui.models.NightlyBuildOption -import org.mozilla.tryfox.ui.models.preferredAbiApk -import org.mozilla.tryfox.util.DEFAULT_PREFERRED_ABI -import org.mozilla.tryfox.util.FENIX -import org.mozilla.tryfox.util.FENIX_BETA -import org.mozilla.tryfox.util.FENIX_RELEASE -import org.mozilla.tryfox.util.FOCUS -import org.mozilla.tryfox.util.FOCUS_BETA -import org.mozilla.tryfox.util.FOCUS_RELEASE -import org.mozilla.tryfox.util.FeatureFlags -import org.mozilla.tryfox.util.REFERENCE_BROWSER -import org.mozilla.tryfox.util.parseDateToLocalDate - -private object ArchiveGroupCardTokens { - val CardPaddingTop = 4.dp - val CardElevation = 6.dp - val ColumnPadding = 16.dp - val AppIconSize = 36.dp - val SpacerHeight = 16.dp - val NoApksPaddingTop = 8.dp -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ArchiveGroupCard( - modifier: Modifier = Modifier, - apks: List, - onDownloadClick: (ApkUiModel) -> Unit, - onInstallClick: (ApkUiModel) -> Unit, - onOpenAppClick: () -> Unit, - onUninstallClick: () -> Unit, - appState: AppState?, - onDateSelected: (LocalDate) -> Unit, - userPickedDate: LocalDate?, - selectedReleaseVersion: String?, - availableReleaseVersions: List, - appName: String, - errorMessage: String?, - isLoading: Boolean, - dateValidator: (LocalDate) -> Boolean, - onClearDate: () -> Unit, - onReleaseVersionSelected: (String) -> Unit, - pendingBuildOptions: List = emptyList(), - onBuildSelected: (String) -> Unit = {}, - onDismissBuildPicker: () -> Unit = {}, - installStates: Map = emptyMap(), - onOpenInstalledApp: (String) -> Unit = {}, - preferredAbi: String = DEFAULT_PREFERRED_ABI, -) { - if (pendingBuildOptions.isNotEmpty()) { - NightlyBuildPickerDialog( - options = pendingBuildOptions, - onSelect = onBuildSelected, - onDismiss = onDismissBuildPicker, - ) - } - ElevatedCard( - modifier = - modifier - .fillMaxWidth() - .padding(top = ArchiveGroupCardTokens.CardPaddingTop), - elevation = CardDefaults.cardElevation(defaultElevation = ArchiveGroupCardTokens.CardElevation), - ) { - val firstApk = apks.firstOrNull() - val version = firstApk?.version ?: "" - val dateFromApk = firstApk?.date ?: "" - // Release, beta and Focus release all pick a specific version from a dropdown. - val hasReleaseVersionPicker = - appName == FENIX_RELEASE || appName == FOCUS_RELEASE || appName == FENIX_BETA || appName == FOCUS_BETA - val isDatePickerEnabled = appName != REFERENCE_BROWSER && !hasReleaseVersionPicker - - Column(modifier = Modifier.padding(ArchiveGroupCardTokens.ColumnPadding)) { - CurrentInstallState( - appState = appState, - appDisplayName = getFriendlyAppName(appName), - ) - - ArchiveGroupHeader( - appName = appName, - version = version, - date = dateFromApk, - onDateSelected = onDateSelected, - userPickedDate = userPickedDate, - selectedReleaseVersion = selectedReleaseVersion, - availableReleaseVersions = availableReleaseVersions, - hasReleaseVersionPicker = hasReleaseVersionPicker, - isDatePickerEnabled = isDatePickerEnabled, - dateValidator = dateValidator, - onClearDate = onClearDate, - onOpenAppClick = onOpenAppClick, - onReleaseVersionSelected = onReleaseVersionSelected, - ) - Spacer(modifier = Modifier.height(ArchiveGroupCardTokens.SpacerHeight)) - - when { - isLoading -> { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 16.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator() - } - } - - errorMessage != null -> { - Text( - text = errorMessage, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(top = ArchiveGroupCardTokens.NoApksPaddingTop), - color = MaterialTheme.colorScheme.error, - ) - } - - apks.isNotEmpty() -> { - ArchiveGroupAbiSelector( - apks, - onDownloadClick, - onInstallClick, - onUninstallClick, - appState, - installStates, - onOpenInstalledApp, - preferredAbi, - ) - } - - else -> { - Text( - stringResource( - id = if (hasReleaseVersionPicker) { - R.string.archive_group_card_no_apks_for_release_version - } else { - R.string.archive_group_card_no_apks_for_date - }, - ), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(top = ArchiveGroupCardTokens.NoApksPaddingTop), - ) - } - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun ArchiveGroupHeader( - appName: String, - version: String, - date: String, - onDateSelected: (LocalDate) -> Unit, - onOpenAppClick: () -> Unit, - userPickedDate: LocalDate?, - selectedReleaseVersion: String?, - availableReleaseVersions: List, - hasReleaseVersionPicker: Boolean, - isDatePickerEnabled: Boolean, - dateValidator: (LocalDate) -> Boolean, - onClearDate: () -> Unit, - onReleaseVersionSelected: (String) -> Unit, -) { - var showDatePicker by remember { mutableStateOf(false) } - // Prefer the loaded build's full timestamp; fall back to the picked date while it loads. - val displayDate = date.ifBlank { userPickedDate?.toString() ?: "" } - val friendlyAppName = getFriendlyAppName(appName) - val showsReleaseVersionPicker = hasReleaseVersionPicker - - Column { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.testTag("app_header_row_${appName.lowercase()}"), - ) { - AppIcon( - appName = appName, - modifier = Modifier - .size(ArchiveGroupCardTokens.AppIconSize) - .clickable { onOpenAppClick() }, - ) - if (showsReleaseVersionPicker) { - Text( - text = friendlyAppName, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - modifier = Modifier.testTag("app_title_text_${appName.lowercase()}"), - ) - // Push the version selector to the right edge of the row. - Spacer(modifier = Modifier.weight(1f)) - VersionSelector( - appName = appName, - selectedReleaseVersion = selectedReleaseVersion ?: version.takeIf { it.isNotEmpty() }, - availableReleaseVersions = availableReleaseVersions, - onReleaseVersionSelected = onReleaseVersionSelected, - ) - } else { - // Fenix/Focus nightlies are identified by name + the timestamp chip below, so the - // build version is redundant on the title. Other cards still show it. - val title = if (appName == FENIX || appName == FOCUS) { - friendlyAppName - } else { - "$friendlyAppName $version" - } - Text( - text = title, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - modifier = Modifier.testTag("app_title_text_${appName.lowercase()}"), - ) - Spacer(modifier = Modifier.weight(1f)) - } - } - - if (!showsReleaseVersionPicker && displayDate.isNotBlank()) { - val chipColors = if (userPickedDate != null) { - AssistChipDefaults.assistChipColors( - containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f), - ) - } else { - AssistChipDefaults.assistChipColors() - } - - Box( - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp), - contentAlignment = Alignment.Center, - ) { - AssistChip( - onClick = { if (isDatePickerEnabled) showDatePicker = true }, - label = { Text(displayDate) }, - colors = chipColors, - trailingIcon = { - if (userPickedDate != null) { - IconButton( - onClick = onClearDate, - modifier = Modifier.size(AssistChipDefaults.IconSize), - ) { - Icon( - imageVector = Icons.Default.Clear, - contentDescription = stringResource(R.string.clear_date_selection), - ) - } - } - }, - modifier = Modifier.testTag("app_date_chip_${appName.lowercase()}"), - ) - } - } - } - - if (showDatePicker) { - val initialDate = userPickedDate ?: parseDateToLocalDate(date) ?: Clock.System.now() - .toLocalDateTime(TimeZone.currentSystemDefault()).date - val datePickerState = rememberDatePickerState( - initialSelectedDateMillis = initialDate.atStartOfDayIn(TimeZone.currentSystemDefault()) - .toEpochMilliseconds(), - selectableDates = object : SelectableDates { - override fun isSelectableDate(utcTimeMillis: Long): Boolean { - val localDate = Instant.fromEpochMilliseconds(utcTimeMillis) - .toLocalDateTime(TimeZone.UTC).date - return dateValidator(localDate) - } - }, - ) - DatePickerDialog( - onDismissRequest = { showDatePicker = false }, - confirmButton = { - TextButton( - onClick = { - showDatePicker = false - datePickerState.selectedDateMillis?.let { - onDateSelected( - Instant.fromEpochMilliseconds(it) - .toLocalDateTime(TimeZone.currentSystemDefault()).date, - ) - } - }, - ) { - Text(stringResource(id = android.R.string.ok)) - } - }, - dismissButton = { - TextButton(onClick = { showDatePicker = false }) { - Text(stringResource(id = android.R.string.cancel)) - } - }, - ) { - DatePicker(state = datePickerState) - } - } -} - -@Composable -internal fun VersionSelector( - appName: String, - selectedReleaseVersion: String?, - availableReleaseVersions: List, - onReleaseVersionSelected: (String) -> Unit, -) { - var showSelector by remember { mutableStateOf(false) } - val selectedVersion = selectedReleaseVersion ?: availableReleaseVersions.firstOrNull() - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .clickable(enabled = availableReleaseVersions.isNotEmpty()) { showSelector = true } - .semantics { - contentDescription = "Selected Firefox Release version ${selectedVersion ?: ""}" - } - .testTag("release_version_chip_${appName.lowercase()}") - .padding(horizontal = 12.dp, vertical = 6.dp), - ) { - Text( - text = selectedVersion ?: "--", - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - ) - Icon( - imageVector = if (showSelector) Icons.Default.ArrowDropUp else Icons.Default.ArrowDropDown, - contentDescription = stringResource(R.string.release_version_chip_description), - ) - } - - if (showSelector) { - VersionSelectorSheet( - appName = appName, - selectedVersion = selectedVersion, - availableVersions = availableReleaseVersions, - onDismiss = { showSelector = false }, - onConfirm = { version -> - showSelector = false - onReleaseVersionSelected(version) - }, - ) - } -} - -private const val MINIMUM_SUPPORTED_MAJOR_VERSION = 117 -private val VersionPickerWheelHeight = 156.dp - -private fun versionMajor(version: String): Int? = - Regex("^(\\d+)(?:\\.|$)").find(version)?.groupValues?.getOrNull(1)?.toIntOrNull() - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun VersionSelectorSheet( - appName: String, - selectedVersion: String?, - availableVersions: List, - onDismiss: () -> Unit, - onConfirm: (String) -> Unit, -) { - val versionsByMajor = remember(availableVersions) { - availableVersions.mapNotNull { version -> versionMajor(version)?.let { it to version } }.groupBy({ it.first }, { it.second }) - } - val initialMajor = versionMajor(selectedVersion.orEmpty()) - ?: versionsByMajor.keys.maxOrNull() - ?: MINIMUM_SUPPORTED_MAJOR_VERSION - val maximumSupportedMajorVersion = versionsByMajor.keys.maxOrNull() ?: MINIMUM_SUPPORTED_MAJOR_VERSION - var activeMajor by remember(selectedVersion, availableVersions) { mutableStateOf(initialMajor) } - var draftVersion by remember(selectedVersion, availableVersions) { mutableStateOf(selectedVersion) } - val isActiveMajorSelectable = activeMajor in MINIMUM_SUPPORTED_MAJOR_VERSION..maximumSupportedMajorVersion - val variants = if (isActiveMajorSelectable) versionsByMajor[activeMajor].orEmpty() else emptyList() - val title = if (appName == FENIX_BETA || appName == FOCUS_BETA) { - stringResource(R.string.version_selector_beta_title) - } else { - stringResource(R.string.version_selector_release_title) - } - val pickerTextColor = MaterialTheme.colorScheme.onSurface.toArgb() - - fun selectMajor(major: Int) { - activeMajor = major - draftVersion = draftVersion?.takeIf { versionMajor(it) == major } - } - - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), - modifier = Modifier.testTag("release_version_selector_sheet_${appName.lowercase()}"), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight(0.85f) - .padding(horizontal = 24.dp, vertical = 8.dp), - ) { - Text(text = title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) - Text( - text = stringResource(R.string.version_selector_helper), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(top = 4.dp, bottom = 16.dp), - ) - Row( - modifier = Modifier - .fillMaxWidth() - .weight(1f) - .padding(start = 16.dp, top = 4.dp), - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.wrapContentWidth(), - ) { - Text( - text = stringResource(R.string.version_selector_version_label), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Bold, - ) - AndroidView( - factory = { context -> - NumberPicker(context).apply { - minValue = MINIMUM_SUPPORTED_MAJOR_VERSION - maxValue = maximumSupportedMajorVersion - value = activeMajor.coerceIn(minValue, maxValue) - wrapSelectorWheel = false - descendantFocusability = ViewGroup.FOCUS_AFTER_DESCENDANTS - setOnValueChangedListener { _, _, newValue -> selectMajor(newValue) } - post { - setSelectorTextColor(pickerTextColor) - editableInput()?.apply { - inputType = InputType.TYPE_CLASS_NUMBER - setSelectAllOnFocus(true) - addTextChangedListener(object : TextWatcher { - override fun beforeTextChanged(text: CharSequence?, start: Int, count: Int, after: Int) = Unit - - override fun onTextChanged(text: CharSequence?, start: Int, before: Int, count: Int) = Unit - - override fun afterTextChanged(text: Editable?) { - text?.toString()?.toIntOrNull()?.takeIf { - it in MINIMUM_SUPPORTED_MAJOR_VERSION..maximumSupportedMajorVersion - }?.let(::selectMajor) - } - }) - } - } - } - }, - update = { picker -> - if (picker.value != activeMajor) picker.value = activeMajor - }, - modifier = Modifier - .wrapContentWidth() - .height(VersionPickerWheelHeight) - .clipToBounds() - .testTag("release_version_major_picker_${appName.lowercase()}"), - ) - } - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .weight(1f) - .padding(start = 24.dp), - ) { - Text( - text = stringResource(R.string.version_selector_available_builds), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(bottom = 8.dp), - ) - if (variants.isEmpty()) { - Box(modifier = Modifier.fillMaxWidth()) { - Text( - text = if (isActiveMajorSelectable) { - stringResource(R.string.version_selector_no_builds) - } else { - stringResource( - R.string.version_selector_current_major_unavailable, - activeMajor, - MINIMUM_SUPPORTED_MAJOR_VERSION, - maximumSupportedMajorVersion, - ) - }, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(vertical = 16.dp), - ) - } - } else { - LazyColumn( - modifier = Modifier - .weight(1f, fill = false) - .wrapContentWidth(), - ) { - items(variants, key = { it }) { version -> - val selected = draftVersion == version - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .wrapContentWidth() - .clickable { - draftVersion = version - onConfirm(version) - } - .semantics { this.selected = selected } - .testTag("release_version_variant_${version.replace('.', '_')}") - .padding(vertical = 2.dp), - ) { - RadioButton( - selected = selected, - onClick = { - draftVersion = version - onConfirm(version) - }, - ) - Text( - text = version, - style = MaterialTheme.typography.bodyLarge, - fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, - modifier = Modifier.padding(start = 12.dp), - ) - } - } - } - } - } - } - Row( - horizontalArrangement = Arrangement.End, - modifier = Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 16.dp), - ) { - TextButton(onClick = onDismiss) { Text(stringResource(id = android.R.string.cancel)) } - } - } - } -} - -private fun NumberPicker.editableInput(): EditText? { - val inputId = resources.getIdentifier("numberpicker_input", "id", "android") - return inputId.takeIf { it != 0 }?.let { findViewById(it) as? EditText } -} - -private fun NumberPicker.setSelectorTextColor(color: Int) { - editableInput()?.setTextColor(color) - if (Build.VERSION.SDK_INT >= 36) { - setTextColor(color) - } -} - -@Suppress("KotlinConstantConditions") -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun ArchiveGroupAbiSelector( - apks: List, - onDownloadClick: (ApkUiModel) -> Unit, - onInstallClick: (ApkUiModel) -> Unit, - onUninstallClick: () -> Unit, - appState: AppState?, - installStates: Map, - onOpenInstalledApp: (String) -> Unit, - preferredAbi: String, -) { - val preferredIndex = apks.preferredAbiApk(preferredAbi) - ?.let(apks::indexOf) - ?.takeIf { it != -1 } - ?: 0 - var selectedIndex by remember { mutableStateOf(preferredIndex) } - - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxWidth(), - ) { - if (FeatureFlags.SHOW_ABI_SELECTOR) { - SingleChoiceSegmentedButtonRow { - apks.forEachIndexed { index, apk -> - val colors = - if (!apk.abi.isSupported) { - SegmentedButtonDefaults.colors( - inactiveContainerColor = MaterialTheme.colorScheme.error.copy(alpha = 0.4f), - activeContainerColor = MaterialTheme.colorScheme.error, - inactiveContentColor = MaterialTheme.colorScheme.onSurface, - activeContentColor = MaterialTheme.colorScheme.onError, - ) - } else { - SegmentedButtonDefaults.colors() - } - SegmentedButton( - selected = selectedIndex == index, - onClick = { selectedIndex = index }, - shape = SegmentedButtonDefaults.itemShape(index = index, count = apks.size), - colors = colors, - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - if (!apk.abi.isSupported) { - Icon( - imageVector = Icons.Default.Warning, - contentDescription = stringResource(R.string.unsupported_abi), - modifier = Modifier - .size(ButtonDefaults.IconSize) - .padding(end = 4.dp), - ) - } - Text( - text = apk.abi.name ?: "", - style = MaterialTheme.typography.labelSmall, - ) - } - } - } - } - - Spacer(Modifier.height(ArchiveGroupCardTokens.SpacerHeight)) - } - - val selectedApk = apks[selectedIndex] - val installState = installStates[selectedApk.uniqueKey] ?: InstallState.Idle - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - if (appState?.isInstalled == true) { - Button( - onClick = onUninstallClick, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer, - ), - ) { - Text(text = stringResource(id = R.string.uninstall_button_label)) - } - } - - DownloadButton( - downloadState = selectedApk.downloadState, - onDownloadClick = { onDownloadClick(selectedApk) }, - onInstallClick = { onInstallClick(selectedApk) }, - installState = installState, - onOpenClick = onOpenInstalledApp, - debugLabel = "home:${selectedApk.uniqueKey}", - ) - } - (installState as? InstallState.Failed)?.let { failure -> - Text( - text = failure.message, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - modifier = Modifier.padding(top = 8.dp), - ) - } - } -} - -/** - * One-shot prompt shown right after picking a Nightly date that has more than one build. Choosing a - * build applies it; dismissing keeps the latest build (already shown). To change later, the user - * re-opens the date picker, which re-triggers this prompt. - */ -@Composable -private fun NightlyBuildPickerDialog( - options: List, - onSelect: (String) -> Unit, - onDismiss: () -> Unit, -) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(id = R.string.nightly_build_picker_title)) }, - text = { - Column(modifier = Modifier.verticalScroll(rememberScrollState())) { - options.forEach { option -> - Text( - text = option.label, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier - .fillMaxWidth() - .clickable { onSelect(option.id) } - .padding(vertical = 12.dp), - ) - } - } - }, - confirmButton = {}, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(id = android.R.string.cancel)) - } - }, - ) -} - -@Composable -private fun getFriendlyAppName(appName: String): String = - when (appName) { - FENIX -> stringResource(id = R.string.app_name_fenix) - FENIX_RELEASE -> stringResource(R.string.app_name_fenix_release) - FENIX_BETA -> stringResource(R.string.app_name_fenix_beta) - FOCUS -> stringResource(id = R.string.app_name_focus) - FOCUS_BETA -> stringResource(id = R.string.app_name_focus_beta) - FOCUS_RELEASE -> stringResource(id = R.string.app_name_focus_release) - REFERENCE_BROWSER -> stringResource(R.string.app_name_reference_browser) - else -> appName - } diff --git a/app/src/main/java/org/mozilla/tryfox/ui/composables/VersionSelector.kt b/app/src/main/java/org/mozilla/tryfox/ui/composables/VersionSelector.kt new file mode 100644 index 0000000..26368e6 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/ui/composables/VersionSelector.kt @@ -0,0 +1,297 @@ +package org.mozilla.tryfox.ui.composables + +import android.os.Build +import android.text.Editable +import android.text.InputType +import android.text.TextWatcher +import android.view.ContextThemeWrapper +import android.view.ViewGroup +import android.widget.EditText +import android.widget.NumberPicker +import androidx.compose.foundation.clickable +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.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.ArrowDropUp +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import org.mozilla.tryfox.R +import org.mozilla.tryfox.util.FENIX_BETA +import org.mozilla.tryfox.util.FOCUS_BETA + +@Composable +internal fun VersionSelector( + appName: String, + selectedReleaseVersion: String?, + availableReleaseVersions: List, + onReleaseVersionSelected: (String) -> Unit, +) { + var showSelector by remember { mutableStateOf(false) } + val selectedVersion = selectedReleaseVersion ?: availableReleaseVersions.firstOrNull() + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable(enabled = availableReleaseVersions.isNotEmpty()) { showSelector = true } + .semantics { + contentDescription = "Selected Firefox Release version ${selectedVersion ?: ""}" + } + .testTag("release_version_chip_${appName.lowercase()}") + .padding(horizontal = 12.dp, vertical = 6.dp), + ) { + Text( + text = selectedVersion ?: "--", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + ) + Icon( + imageVector = if (showSelector) Icons.Default.ArrowDropUp else Icons.Default.ArrowDropDown, + contentDescription = stringResource(R.string.release_version_chip_description), + ) + } + + if (showSelector) { + VersionSelectorSheet( + appName = appName, + selectedVersion = selectedVersion, + availableVersions = availableReleaseVersions, + onDismiss = { showSelector = false }, + onConfirm = { version -> + showSelector = false + onReleaseVersionSelected(version) + }, + ) + } +} + +private const val MINIMUM_SUPPORTED_MAJOR_VERSION = 117 +private val VersionPickerWheelHeight = 156.dp + +private fun versionMajor(version: String): Int? = + Regex("^(\\d+)(?:\\.|$)").find(version)?.groupValues?.getOrNull(1)?.toIntOrNull() + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun VersionSelectorSheet( + appName: String, + selectedVersion: String?, + availableVersions: List, + onDismiss: () -> Unit, + onConfirm: (String) -> Unit, +) { + val versionsByMajor = remember(availableVersions) { + availableVersions.mapNotNull { version -> versionMajor(version)?.let { it to version } }.groupBy({ it.first }, { it.second }) + } + val initialMajor = versionMajor(selectedVersion.orEmpty()) + ?: versionsByMajor.keys.maxOrNull() + ?: MINIMUM_SUPPORTED_MAJOR_VERSION + val maximumSupportedMajorVersion = versionsByMajor.keys.maxOrNull() ?: MINIMUM_SUPPORTED_MAJOR_VERSION + var activeMajor by remember(selectedVersion, availableVersions) { mutableStateOf(initialMajor) } + var draftVersion by remember(selectedVersion, availableVersions) { mutableStateOf(selectedVersion) } + val isActiveMajorSelectable = activeMajor in MINIMUM_SUPPORTED_MAJOR_VERSION..maximumSupportedMajorVersion + val variants = if (isActiveMajorSelectable) versionsByMajor[activeMajor].orEmpty() else emptyList() + val title = if (appName == FENIX_BETA || appName == FOCUS_BETA) { + stringResource(R.string.version_selector_beta_title) + } else { + stringResource(R.string.version_selector_release_title) + } + val pickerTextColor = MaterialTheme.colorScheme.onSurface.toArgb() + + fun selectMajor(major: Int) { + activeMajor = major + draftVersion = draftVersion?.takeIf { versionMajor(it) == major } + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + modifier = Modifier.testTag("release_version_selector_sheet_${appName.lowercase()}"), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(0.85f) + .padding(horizontal = 24.dp, vertical = 8.dp), + ) { + Text(text = title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) + Text( + text = stringResource(R.string.version_selector_helper), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 4.dp, bottom = 16.dp), + ) + Row( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(start = 16.dp, top = 4.dp), + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.wrapContentWidth(), + ) { + Text( + text = stringResource(R.string.version_selector_version_label), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + ) + AndroidView( + factory = { context -> + NumberPicker(ContextThemeWrapper(context, R.style.ThemeOverlay_TryFox_VersionPicker)).apply { + minValue = MINIMUM_SUPPORTED_MAJOR_VERSION + maxValue = maximumSupportedMajorVersion + value = activeMajor.coerceIn(minValue, maxValue) + wrapSelectorWheel = false + descendantFocusability = ViewGroup.FOCUS_AFTER_DESCENDANTS + setOnValueChangedListener { _, _, newValue -> selectMajor(newValue) } + post { + setSelectorTextColor(pickerTextColor) + editableInput()?.apply { + inputType = InputType.TYPE_CLASS_NUMBER + setSelectAllOnFocus(true) + addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(text: CharSequence?, start: Int, count: Int, after: Int) = Unit + + override fun onTextChanged(text: CharSequence?, start: Int, before: Int, count: Int) = Unit + + override fun afterTextChanged(text: Editable?) { + text?.toString()?.toIntOrNull()?.takeIf { + it in MINIMUM_SUPPORTED_MAJOR_VERSION..maximumSupportedMajorVersion + }?.let(::selectMajor) + } + }) + } + } + } + }, + update = { picker -> + if (picker.value != activeMajor) picker.value = activeMajor + }, + modifier = Modifier + .wrapContentWidth() + .height(VersionPickerWheelHeight) + .clipToBounds() + .testTag("release_version_major_picker_${appName.lowercase()}"), + ) + } + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .weight(1f) + .padding(start = 24.dp), + ) { + Text( + text = stringResource(R.string.version_selector_available_builds), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 8.dp), + ) + if (variants.isEmpty()) { + Box(modifier = Modifier.fillMaxWidth()) { + Text( + text = if (isActiveMajorSelectable) { + stringResource(R.string.version_selector_no_builds) + } else { + stringResource( + R.string.version_selector_current_major_unavailable, + activeMajor, + MINIMUM_SUPPORTED_MAJOR_VERSION, + maximumSupportedMajorVersion, + ) + }, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(vertical = 16.dp), + ) + } + } else { + LazyColumn( + modifier = Modifier + .weight(1f, fill = false) + .wrapContentWidth(), + ) { + items(variants, key = { it }) { version -> + val selected = draftVersion == version + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .wrapContentWidth() + .clickable { + draftVersion = version + onConfirm(version) + } + .semantics { this.selected = selected } + .testTag("release_version_variant_${version.replace('.', '_')}") + .padding(vertical = 2.dp), + ) { + RadioButton( + selected = selected, + onClick = { + draftVersion = version + onConfirm(version) + }, + ) + Text( + text = version, + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + modifier = Modifier.padding(start = 12.dp), + ) + } + } + } + } + } + } + Row( + horizontalArrangement = Arrangement.End, + modifier = Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 16.dp), + ) { + TextButton(onClick = onDismiss) { Text(stringResource(id = android.R.string.cancel)) } + } + } + } +} + +private fun NumberPicker.editableInput(): EditText? { + val inputId = resources.getIdentifier("numberpicker_input", "id", "android") + return inputId.takeIf { it != 0 }?.let { findViewById(it) as? EditText } +} + +private fun NumberPicker.setSelectorTextColor(color: Int) { + editableInput()?.setTextColor(color) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + setTextColor(color) + } +} diff --git a/app/src/main/java/org/mozilla/tryfox/util/FeatureFlags.kt b/app/src/main/java/org/mozilla/tryfox/util/FeatureFlags.kt deleted file mode 100644 index eae4db2..0000000 --- a/app/src/main/java/org/mozilla/tryfox/util/FeatureFlags.kt +++ /dev/null @@ -1,5 +0,0 @@ -package org.mozilla.tryfox.util - -object FeatureFlags { - const val SHOW_ABI_SELECTOR: Boolean = false -} diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml new file mode 100644 index 0000000..4cf8d71 --- /dev/null +++ b/app/src/main/res/values-night/colors.xml @@ -0,0 +1,5 @@ + + + + #FFE6E1E5 + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index f8c6127..aa4c654 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -7,4 +7,6 @@ #FF018786 #FF000000 #FFFFFFFF + + #FF1C1B1F \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 603c317..b19dd6b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -76,8 +76,6 @@ No APK details available. Supported by your device (%1$d) Not supported by your device (%1$d) - No APKs found for this date. - No APKs found for this release. Search builds Loading pushes… No pushes found for this author. @@ -109,15 +107,6 @@ Last installed: %1$s Remove history entry - Fenix Nightly - Fenix Release - Fenix Beta - Focus Nightly - Focus Release - Focus Beta - Reference Browser - Unsupported ABI - Clear date selection Select Firefox Release version Choose Release version Choose Beta version @@ -129,8 +118,6 @@ Builds: No builds are available for this major version. The current major version (%1$d) is outside the selectable range. Choose a version from %2$d to %3$d. - Select build - Uninstall Installed Not installed Play Store diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 1e2b116..6940f4d 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -2,4 +2,12 @@ \ No newline at end of file