fix(settings): About tab crash + update-row UI (#889)
- AppUpdateViewModel has no (Application) ctor; the default viewModel()
factory's AndroidViewModelFactory does getConstructor(Application)
-> NoSuchMethodException -> crash on opening About. Build it with an
explicit viewModel { } initializer + APPLICATION_KEY instead.
- Add instrumented regression test that renders SettingsAboutPage with
the real default factory (the exact path that crashed).
- UpdateRow: status text and action button no longer fight for one line;
Update now / Open settings / Retry get a full-width button of their
own below the row, Downloading gets a full-width progress bar.
- Strip the leading 'v' from GitHub release tags before formatting
(templates already prepend 'v' -> was rendering 'vv1.21.1').
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
683a24becf
commit
ff8370bb8d
3 files changed
+136
-53
No files matched your search
@@ -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.<init>(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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
@@ -199,7 +200,17 @@ internal fun SettingsBehaviorPage(
|
|||||||
@Composable
|
@Composable
|
||||||
internal fun SettingsAboutPage(
|
internal fun SettingsAboutPage(
|
||||||
onBack: () -> Unit,
|
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.<init>(Application).
|
||||||
|
AppUpdateViewModel(app)
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
val updateState by viewModel.state.collectAsStateWithLifecycle()
|
val updateState by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
package com.m57.hermescontrol.ui.settings.components
|
package com.m57.hermescontrol.ui.settings.components
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
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.HorizontalDivider
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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
|
@Composable
|
||||||
private fun UpdateRow(
|
private fun UpdateRow(
|
||||||
state: AppUpdateState,
|
state: AppUpdateState,
|
||||||
@@ -95,76 +91,118 @@ private fun UpdateRow(
|
|||||||
onStartUpdate: () -> Unit,
|
onStartUpdate: () -> Unit,
|
||||||
onOpenInstallSettings: () -> 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 =
|
val tappable =
|
||||||
state is AppUpdateState.Idle ||
|
state is AppUpdateState.Idle ||
|
||||||
state is AppUpdateState.UpToDate ||
|
state is AppUpdateState.UpToDate ||
|
||||||
state is AppUpdateState.Error
|
state is AppUpdateState.Error
|
||||||
Row(
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
modifier =
|
Row(
|
||||||
Modifier
|
modifier =
|
||||||
.fillMaxWidth()
|
Modifier
|
||||||
.padding(vertical = 4.dp)
|
.fillMaxWidth()
|
||||||
.clickable(enabled = tappable) { onCheckUpdate() },
|
.padding(vertical = 4.dp)
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
.clickable(enabled = tappable) { onCheckUpdate() },
|
||||||
) {
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
Text(
|
) {
|
||||||
text = stringResource(R.string.settings_about_update),
|
Text(
|
||||||
style =
|
text = stringResource(R.string.settings_about_update),
|
||||||
MaterialTheme.typography.bodyMedium.copy(
|
style =
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
MaterialTheme.typography.bodyMedium.copy(
|
||||||
),
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
),
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
)
|
||||||
|
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) {
|
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 -> {
|
is AppUpdateState.UpdateAvailable -> {
|
||||||
ValueText(stringResource(R.string.settings_about_update_available, state.latestTag))
|
Button(
|
||||||
TextButton(onClick = onStartUpdate) {
|
onClick = onStartUpdate,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||||
|
) {
|
||||||
Text(stringResource(R.string.settings_about_update_action))
|
Text(stringResource(R.string.settings_about_update_action))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is AppUpdateState.Downloading -> {
|
is AppUpdateState.Downloading -> {
|
||||||
ValueText(
|
|
||||||
stringResource(
|
|
||||||
R.string.settings_about_update_downloading,
|
|
||||||
(state.progress * 100).toInt(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
LinearProgressIndicator(
|
LinearProgressIndicator(
|
||||||
progress = { state.progress },
|
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 -> {
|
is AppUpdateState.NeedsUnknownSourcesPermission -> {
|
||||||
ValueText(stringResource(R.string.settings_about_update_allow_sources))
|
OutlinedButton(
|
||||||
TextButton(onClick = onOpenInstallSettings) {
|
onClick = onOpenInstallSettings,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||||
|
) {
|
||||||
Text(stringResource(R.string.settings_about_update_open_settings))
|
Text(stringResource(R.string.settings_about_update_open_settings))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is AppUpdateState.Error -> {
|
is AppUpdateState.Error -> {
|
||||||
ValueText(state.message)
|
OutlinedButton(
|
||||||
TextButton(onClick = onCheckUpdate) {
|
onClick = onCheckUpdate,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||||
|
) {
|
||||||
Text(stringResource(R.string.settings_about_update_retry))
|
Text(stringResource(R.string.settings_about_update_retry))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user