Match the Join tab to the converter's empty state

Applies the same treatment to the Join tab: label and button centred on both
axes, button filling the width at 56dp tall, and the same asymmetric screen
padding. An empty state that looks different depending on which tab you are
on reads as a bug rather than as variety.

The two shared dimensions move into ui/Dimens.kt rather than being duplicated
per screen, so the tabs cannot drift apart later.

Verified on an API 37 emulator: both tabs now present an identical empty
state. 65 unit and 17 instrumented tests still pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-20 09:09:33 -05:00
co-authored by Claude Opus 5
parent a8fa3aecdf
commit e5f274183c
4 changed files with 145 additions and 76 deletions
@@ -26,8 +26,10 @@ 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 dev.jasonmross.mediaconverter.ui.PrimaryButtonHeight
import dev.jasonmross.mediaconverter.ui.ScreenPaddingHorizontal
import dev.jasonmross.mediaconverter.ui.ScreenPaddingVertical
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.media3.common.util.UnstableApi
@@ -36,17 +38,6 @@ 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(
@@ -78,7 +69,7 @@ fun ConverterScreen(
Column(
modifier = modifier
.fillMaxSize()
.padding(horizontal = ScreenPadding, vertical = 24.dp),
.padding(horizontal = ScreenPaddingHorizontal, vertical = ScreenPaddingVertical),
) {
Text(
"Media Converter",
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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
@@ -17,13 +18,18 @@ 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.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.media3.common.util.UnstableApi
import dev.jasonmross.mediaconverter.convert.InputFile
import dev.jasonmross.mediaconverter.model.ConcatStrategy
import dev.jasonmross.mediaconverter.ui.PrimaryButtonHeight
import dev.jasonmross.mediaconverter.ui.ScreenPaddingHorizontal
import dev.jasonmross.mediaconverter.ui.ScreenPaddingVertical
@UnstableApi
@Composable
@@ -44,77 +50,125 @@ fun JoinScreen(
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
.padding(horizontal = ScreenPaddingHorizontal, vertical = ScreenPaddingVertical),
) {
Text("Join files", style = MaterialTheme.typography.headlineMedium)
Text(
"Join files",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(bottom = 16.dp),
)
// Same split as the converter screen: the empty state is centred, the working
// states scroll because their content can exceed the screen.
val body = Modifier.fillMaxWidth().weight(1f)
when (val s = state) {
is JoinState.Idle -> {
is JoinState.Idle -> Column(
modifier = body,
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
"Pick two or more files to join, in the order you want them.",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
modifier = Modifier.padding(bottom = 16.dp),
)
Button(onClick = { pickInputs.launch(arrayOf("video/*")) }) {
Text("Choose files")
Button(
onClick = { pickInputs.launch(arrayOf("video/*")) },
modifier = Modifier
.fillMaxWidth()
.height(PrimaryButtonHeight),
) { Text("Choose files") }
}
else -> Column(
modifier = body.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
when (s) {
is JoinState.Idle -> Unit
is JoinState.Ready -> {
s.inputs.forEach { FileRow(it) }
Button(
onClick = viewModel::join,
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
) { Text("Join ${s.inputs.size} files") }
OutlinedButton(
onClick = { pickInputs.launch(arrayOf("video/*")) },
modifier = Modifier.fillMaxWidth(),
) { Text("Choose different files") }
}
is JoinState.Joining -> {
Text("Joining ${s.inputs.size} files…")
// Indeterminate on purpose: FFmpeg reports progress against a
// single input's duration, which means nothing across a
// concatenation. A fabricated percentage would be worse than none.
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
OutlinedButton(
onClick = viewModel::cancel,
modifier = Modifier.fillMaxWidth(),
) { Text("Cancel") }
}
is JoinState.Waiting -> {
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 JoinState.Joined -> {
Text("Joined — ${s.staged.length() / 1_000_000} MB.")
Text(
when (s.strategy) {
ConcatStrategy.STREAM_COPY ->
"Files matched, so they were joined without " +
"re-encoding — no quality loss."
ConcatStrategy.REENCODE ->
"Files differed in format, so they were re-encoded " +
"to match."
},
style = MaterialTheme.typography.bodySmall,
)
Button(
onClick = { chooseDestination.launch("joined.mp4") },
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
) { Text("Save file") }
OutlinedButton(
onClick = viewModel::reset,
modifier = Modifier.fillMaxWidth(),
) { Text("Start over") }
}
is JoinState.Saved -> {
Text("Saved ${s.displayName}.", style = MaterialTheme.typography.bodyLarge)
Button(
onClick = viewModel::reset,
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
) { Text("Join more") }
}
is JoinState.Failed -> {
Text(
s.message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
Button(
onClick = viewModel::reset,
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
) { Text("Start over") }
}
}
}
is JoinState.Ready -> {
s.inputs.forEach { FileRow(it) }
Button(onClick = viewModel::join) { Text("Join ${s.inputs.size} files") }
OutlinedButton(onClick = { pickInputs.launch(arrayOf("video/*")) }) {
Text("Choose different files")
}
}
is JoinState.Joining -> {
Text("Joining ${s.inputs.size} files…")
// Indeterminate on purpose: FFmpeg reports progress against a single
// input's duration, which means nothing across a concatenation. A
// fabricated percentage would be worse than none.
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
OutlinedButton(onClick = viewModel::cancel) { Text("Cancel") }
}
is JoinState.Waiting -> {
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 JoinState.Joined -> {
Text("Joined — ${s.staged.length() / 1_000_000} MB.")
Text(
when (s.strategy) {
ConcatStrategy.STREAM_COPY ->
"Files matched, so they were joined without re-encoding — no quality loss."
ConcatStrategy.REENCODE ->
"Files differed in format, so they were re-encoded to match."
},
style = MaterialTheme.typography.bodySmall,
)
Button(onClick = { chooseDestination.launch("joined.mp4") }) { Text("Save file") }
OutlinedButton(onClick = viewModel::reset) { Text("Start over") }
}
is JoinState.Saved -> {
Text("Saved ${s.displayName}.", style = MaterialTheme.typography.bodyLarge)
Button(onClick = viewModel::reset) { Text("Join more") }
}
is JoinState.Failed -> {
Text(
s.message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
Button(onClick = viewModel::reset) { Text("Start over") }
}
}
}
}
@@ -0,0 +1,24 @@
package dev.jasonmross.mediaconverter.ui
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Layout constants shared by the top-level screens.
*
* Shared rather than duplicated so the two tabs cannot drift apart: an empty state that
* looks different depending on which tab you are on reads as a bug.
*/
/** Primary actions are taller than the Material default so they read as the main affordance. */
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.
*/
val ScreenPaddingHorizontal: Dp = 16.dp
val ScreenPaddingVertical: Dp = 24.dp