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:
M57andM57 authored and GitHub committed 2026-08-13 00:14:41 +03:00
1 parent 683a24becf
commit ff8370bb8d
3 files changed
+136 -53

No files matched your search

@@ -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.<init>(Application).
AppUpdateViewModel(app)
},
) {
val updateState by viewModel.state.collectAsStateWithLifecycle()
val context = LocalContext.current
@@ -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 -> {}
}
}
}