Centre the empty state and widen its primary button

Splits the converter screen into a fixed header and a body region that
behaves differently per state. The empty state centres its label and button
on both axes in whatever space is left; the working states keep scrolling,
since format pickers and progress can exceed the screen and centring content
that overflows would push it out of reach.

The primary button now fills the width and stands 56dp tall rather than the
Material default of 40dp, so it reads as the main affordance instead of a
small control adrift in an otherwise empty screen. Horizontal screen padding
drops to 16dp while vertical stays at 24dp, which is what lets a full-width
button sit close to both edges.

Both dimensions are named constants rather than inline numbers, because the
same treatment is applied to the primary action in every other state.

No theme changes were needed. Dark mode already tracked the device: the
Compose theme reads isSystemInDarkTheme() and the activity theme has a
values-night variant. Verified on an API 37 emulator by toggling
`cmd uimode night` and capturing both -- mean luminance 245/255 in light
against 18/255 in dark, with the button recolouring and status bar icons
inverting correctly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-20 09:06:09 -05:00
co-authored by Claude Opus 5
parent 2fcacd199c
commit a8fa3aecdf
4 changed files with 133 additions and 82 deletions
Generated
-1
View File
@@ -1 +0,0 @@
Android(MediaConverter
+1
View File
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
+1 -1
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_25" project-jdk-name="jbr-25" project-jdk-type="JavaSDK">
<component name="ProjectRootManager" version="2" languageLevel="JDK_25" default="true" project-jdk-name="temurin-25" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
@@ -5,8 +5,11 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
@@ -20,10 +23,11 @@ import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.media3.common.util.UnstableApi
@@ -32,6 +36,17 @@ import dev.jasonmross.mediaconverter.model.OutputFormat
import dev.jasonmross.mediaconverter.model.QualityTier
import java.util.Locale
/** Primary actions are taller than the Material default so they read as the main affordance. */
private val PrimaryButtonHeight: Dp = 56.dp
/**
* Horizontal screen inset.
*
* Deliberately tighter than the vertical inset so a full-width primary button reaches
* close to both edges of the display.
*/
private val ScreenPadding: Dp = 16.dp
@UnstableApi
@Composable
fun ConverterScreen(
@@ -63,95 +78,131 @@ fun ConverterScreen(
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
.padding(horizontal = ScreenPadding, vertical = 24.dp),
) {
Text("Media Converter", style = MaterialTheme.typography.headlineMedium)
Text(
"Media Converter",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(bottom = 16.dp),
)
// The empty state is centred in whatever space is left. The working states
// scroll instead, since their content can exceed the screen.
val body = Modifier.fillMaxWidth().weight(1f)
when (val s = state) {
is ConversionState.Idle -> {
is ConversionState.Idle -> Column(
modifier = body,
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
"Pick a file to convert.",
style = MaterialTheme.typography.bodyMedium,
)
Button(onClick = { pickInput.launch(arrayOf("*/*")) }) { Text("Choose file") }
}
is ConversionState.Ready -> {
FileCard(s.input)
FormatPicker(
selected = settings.format,
onSelect = viewModel::setFormat,
)
QualityPicker(
selected = settings.quality,
onSelect = viewModel::setQuality,
)
EnginePicker(
selected = settings.enginePreference,
onSelect = viewModel::setEnginePreference,
textAlign = TextAlign.Center,
modifier = Modifier.padding(bottom = 16.dp),
)
Button(
onClick = {
requestNotifications.launch(Manifest.permission.POST_NOTIFICATIONS)
onClick = { pickInput.launch(arrayOf("*/*")) },
modifier = Modifier
.fillMaxWidth()
.height(PrimaryButtonHeight),
) { Text("Choose file") }
}
else -> Column(
modifier = body.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
when (s) {
is ConversionState.Idle -> Unit
is ConversionState.Ready -> {
FileCard(s.input)
FormatPicker(settings.format, viewModel::setFormat)
QualityPicker(settings.quality, viewModel::setQuality)
EnginePicker(settings.enginePreference, viewModel::setEnginePreference)
Button(
onClick = {
requestNotifications.launch(Manifest.permission.POST_NOTIFICATIONS)
},
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
) { Text("Convert") }
OutlinedButton(
onClick = { pickInput.launch(arrayOf("*/*")) },
modifier = Modifier.fillMaxWidth(),
) { Text("Choose a different file") }
}
is ConversionState.Converting -> {
FileCard(s.input)
Text("Converting… ${s.percent}%")
LinearProgressIndicator(
progress = { s.percent / 100f },
modifier = Modifier.fillMaxWidth(),
)
OutlinedButton(
onClick = viewModel::cancel,
modifier = Modifier.fillMaxWidth(),
) { Text("Cancel") }
}
is ConversionState.Waiting -> {
FileCard(s.input)
Text(
"Paused. The system limits background media processing to " +
"six hours a day, so this will resume automatically.",
style = MaterialTheme.typography.bodyMedium,
)
OutlinedButton(
onClick = viewModel::cancel,
modifier = Modifier.fillMaxWidth(),
) { Text("Cancel") }
}
is ConversionState.Converted -> {
FileCard(s.input)
Text(
"Done — ${formatBytes(s.staged.length())} output.",
style = MaterialTheme.typography.bodyMedium,
)
if (s.routeReason.isNotBlank()) {
// Surfacing the routing decision rather than hiding it: it
// explains why a job was slow, and makes the software
// fallback visible.
AssistChip(onClick = {}, label = { Text(s.routeReason) })
}
Button(
onClick = { chooseDestination.launch(viewModel.suggestedOutputName()) },
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
) { Text("Save file") }
OutlinedButton(
onClick = viewModel::reset,
modifier = Modifier.fillMaxWidth(),
) { Text("Start over") }
}
is ConversionState.Saved -> {
Text("Saved ${s.displayName}.", style = MaterialTheme.typography.bodyLarge)
Button(
onClick = viewModel::reset,
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
) { Text("Convert another") }
}
is ConversionState.Failed -> {
Text(
s.message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
Button(
onClick = viewModel::reset,
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
) { Text("Start over") }
}
) { Text("Convert") }
OutlinedButton(onClick = { pickInput.launch(arrayOf("*/*")) }) {
Text("Choose a different file")
}
}
is ConversionState.Converting -> {
FileCard(s.input)
Text("Converting… ${s.percent}%")
LinearProgressIndicator(
progress = { s.percent / 100f },
modifier = Modifier.fillMaxWidth(),
)
OutlinedButton(onClick = viewModel::cancel) { Text("Cancel") }
}
is ConversionState.Waiting -> {
FileCard(s.input)
Text(
"Paused. The system limits background media processing to six " +
"hours a day, so this will resume automatically.",
style = MaterialTheme.typography.bodyMedium,
)
OutlinedButton(onClick = viewModel::cancel) { Text("Cancel") }
}
is ConversionState.Converted -> {
FileCard(s.input)
Text(
"Done — ${formatBytes(s.staged.length())} output.",
style = MaterialTheme.typography.bodyMedium,
)
if (s.routeReason.isNotBlank()) {
// Surfacing the routing decision rather than hiding it: it explains
// why a job was slow, and makes the software fallback visible.
AssistChip(onClick = {}, label = { Text(s.routeReason) })
}
Button(onClick = { chooseDestination.launch(viewModel.suggestedOutputName()) }) {
Text("Save file")
}
OutlinedButton(onClick = viewModel::reset) { Text("Start over") }
}
is ConversionState.Saved -> {
Text("Saved ${s.displayName}.", style = MaterialTheme.typography.bodyLarge)
Button(onClick = viewModel::reset) { Text("Convert another") }
}
is ConversionState.Failed -> {
Text(
s.message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
Button(onClick = viewModel::reset) { Text("Start over") }
}
}
}
}