diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/settings/SettingsAboutPageTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/settings/SettingsAboutPageTest.kt new file mode 100644 index 00000000..c6afe5b5 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/settings/SettingsAboutPageTest.kt @@ -0,0 +1,34 @@ +package com.m57.hermescontrol.ui.settings + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Regression net for the About-tab crash (issue: opening About threw + * NoSuchMethodException AppUpdateViewModel.(Application) — the default + * AndroidViewModelFactory resolves ctors via reflection, which cannot see the + * Kotlin default-arg synthetic constructor). The test deliberately does NOT + * inject a view model: the default `viewModel { ... }` factory lambda must + * build the real [AppUpdateViewModel] without crashing. + */ +@RunWith(AndroidJUnit4::class) +@MediumTest +class SettingsAboutPageTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun aboutPage_opensWithoutCrashing() { + composeTestRule.setContent { + SettingsAboutPage(onBack = {}) + } + + composeTestRule.onNodeWithText("About").assertIsDisplayed() + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt index 6b42c352..5ef5bbdd 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.m57.hermescontrol.R @@ -199,7 +200,17 @@ internal fun SettingsBehaviorPage( @Composable internal fun SettingsAboutPage( onBack: () -> Unit, - viewModel: AppUpdateViewModel = viewModel(), + viewModel: AppUpdateViewModel = + viewModel { + val app = + this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] + ?: error("Application not available") + // Explicit factory lambda: the default AndroidViewModelFactory + // resolves ctors via reflection, which cannot see Kotlin + // default-arg synthetic constructors — opening About crashed with + // NoSuchMethodException AppUpdateViewModel.(Application). + AppUpdateViewModel(app) + }, ) { val updateState by viewModel.state.collectAsStateWithLifecycle() val context = LocalContext.current diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AboutSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AboutSection.kt index 74dd4514..df85bc8a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AboutSection.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AboutSection.kt @@ -1,17 +1,18 @@ package com.m57.hermescontrol.ui.settings.components import androidx.compose.foundation.clickable +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.width +import androidx.compose.material3.Button import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -83,11 +84,6 @@ internal fun AboutSection( } } -/** - * In-app update row (issue #867): tap to check GitHub releases; when a newer - * release exists, download + install from here. Idle/up-to-date/error states - * are tappable to re-check; actionable states show a labeled button. - */ @Composable private fun UpdateRow( state: AppUpdateState, @@ -95,76 +91,118 @@ private fun UpdateRow( onStartUpdate: () -> Unit, onOpenInstallSettings: () -> Unit, ) { + // GitHub release tags carry a leading "v" (e.g. "v1.21.1"); the string + // templates below already prepend one, so strip it here to avoid "vv". + val tag = + (state as? AppUpdateState.UpToDate)?.latestTag + ?: (state as? AppUpdateState.UpdateAvailable)?.latestTag + ?: (state as? AppUpdateState.Installing)?.latestTag + val displayTag = tag?.trimStart('v').orEmpty() val tappable = state is AppUpdateState.Idle || state is AppUpdateState.UpToDate || state is AppUpdateState.Error - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 4.dp) - .clickable(enabled = tappable) { onCheckUpdate() }, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(R.string.settings_about_update), - style = - MaterialTheme.typography.bodyMedium.copy( - color = MaterialTheme.colorScheme.onSurfaceVariant, - ), - ) - Spacer(modifier = Modifier.weight(1f)) + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clickable(enabled = tappable) { onCheckUpdate() }, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.settings_about_update), + style = + MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) + Spacer(modifier = Modifier.weight(1f)) + when (state) { + is AppUpdateState.Idle -> { + ValueText(stringResource(R.string.settings_about_update_check)) + } + + is AppUpdateState.Checking -> { + ValueText(stringResource(R.string.settings_about_update_checking)) + } + + is AppUpdateState.UpToDate -> { + ValueText( + stringResource(R.string.settings_about_update_uptodate, displayTag), + ) + } + + is AppUpdateState.UpdateAvailable -> { + ValueText( + stringResource(R.string.settings_about_update_available, displayTag), + ) + } + + is AppUpdateState.Downloading -> { + ValueText( + stringResource( + R.string.settings_about_update_downloading, + (state.progress * 100).toInt(), + ), + ) + } + + is AppUpdateState.Installing -> { + ValueText( + stringResource(R.string.settings_about_update_installing, displayTag), + ) + } + + is AppUpdateState.NeedsUnknownSourcesPermission -> { + ValueText(stringResource(R.string.settings_about_update_allow_sources)) + } + + is AppUpdateState.Error -> { + ValueText(state.message) + } + } + } + + // Action area: the button/progress gets its own full-width line so a + // long status text or error message never fights it for space. when (state) { - is AppUpdateState.Idle -> { - ValueText(stringResource(R.string.settings_about_update_check)) - } - - is AppUpdateState.Checking -> { - ValueText(stringResource(R.string.settings_about_update_checking)) - } - - is AppUpdateState.UpToDate -> { - ValueText(stringResource(R.string.settings_about_update_uptodate, state.latestTag)) - } - is AppUpdateState.UpdateAvailable -> { - ValueText(stringResource(R.string.settings_about_update_available, state.latestTag)) - TextButton(onClick = onStartUpdate) { + Button( + onClick = onStartUpdate, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) { Text(stringResource(R.string.settings_about_update_action)) } } is AppUpdateState.Downloading -> { - ValueText( - stringResource( - R.string.settings_about_update_downloading, - (state.progress * 100).toInt(), - ), - ) LinearProgressIndicator( progress = { state.progress }, - modifier = Modifier.width(80.dp).padding(start = 8.dp), + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), ) } - is AppUpdateState.Installing -> { - ValueText(stringResource(R.string.settings_about_update_installing, state.latestTag)) - } - is AppUpdateState.NeedsUnknownSourcesPermission -> { - ValueText(stringResource(R.string.settings_about_update_allow_sources)) - TextButton(onClick = onOpenInstallSettings) { + OutlinedButton( + onClick = onOpenInstallSettings, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) { Text(stringResource(R.string.settings_about_update_open_settings)) } } is AppUpdateState.Error -> { - ValueText(state.message) - TextButton(onClick = onCheckUpdate) { + OutlinedButton( + onClick = onCheckUpdate, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) { Text(stringResource(R.string.settings_about_update_retry)) } } + + else -> {} } } }