feat(android): bottom tab bar (Connect / Settings)
apple / swift (push) Successful in 53s
ci / web (push) Successful in 35s
ci / docs-site (push) Successful in 35s
ci / bench (push) Successful in 1m48s
deb / build-publish (push) Successful in 3m28s
decky / build-publish (push) Successful in 10s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
ci / rust (push) Successful in 6m59s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 5m46s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 5m44s
docker / deploy-docs (push) Successful in 19s
android / android (push) Successful in 2m41s

Replace the ad-hoc screen switching with a Material3 bottom NavigationBar. Two top-level
destinations — Connect (Home icon) and Settings (gear) — persist across tab switches; the
immersive stream view is shown full-screen, outside the bar. Settings is now a tab, so its
button is dropped from the Connect screen.

- app/build.gradle.kts: + androidx.compose.material:material-icons-core (tab icons).
- MainActivity: Screen sealed interface -> Tab enum; App() wraps the tabs in a Scaffold with a
  NavigationBar bottomBar (streamHandle != 0 -> StreamScreen full-screen); ConnectScreen drops
  the onOpenSettings param + the Settings button.

Verified live (emulator): the bar renders with Connect/Settings; tapping a tab swaps content and
moves the selected indicator; the bar persists on both tabs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 16:39:55 +02:00
parent 8446ca1e47
commit 14fe450b72
2 changed files with 46 additions and 19 deletions
+1
View File
@@ -59,6 +59,7 @@ dependencies {
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-core") // bottom-bar tab icons
debugImplementation("androidx.compose.ui:ui-tooling")
// Android TV components (we target phone + TV) land in the TV-UI milestone:
@@ -31,9 +31,16 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -48,6 +55,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.platform.LocalContext
@@ -138,10 +146,10 @@ class MainActivity : ComponentActivity() {
}
}
private sealed interface Screen {
data object Connect : Screen
data object Settings : Screen
data class Stream(val handle: Long) : Screen
/** Bottom-bar destinations (the immersive stream view is shown full-screen, outside the bar). */
private enum class Tab(val label: String, val icon: ImageVector) {
Connect("Connect", Icons.Filled.Home),
Settings("Settings", Icons.Filled.Settings),
}
/**
@@ -165,24 +173,43 @@ private fun App() {
val context = LocalContext.current
val settingsStore = remember { SettingsStore(context) }
var settings by remember { mutableStateOf(settingsStore.load()) }
var screen by remember { mutableStateOf<Screen>(Screen.Connect) }
when (val s = screen) {
Screen.Connect -> ConnectScreen(
settings = settings,
onConnected = { handle -> screen = Screen.Stream(handle) },
onOpenSettings = { screen = Screen.Settings },
var streamHandle by remember { mutableStateOf(0L) } // 0 = not streaming
var tab by remember { mutableStateOf(Tab.Connect) }
if (streamHandle != 0L) {
// Immersive: the stream takes the whole screen, no bottom bar.
StreamScreen(streamHandle, onDisconnect = { streamHandle = 0L })
} else {
Scaffold(
bottomBar = {
NavigationBar {
Tab.entries.forEach { t ->
NavigationBarItem(
selected = tab == t,
onClick = { tab = t },
icon = { Icon(t.icon, contentDescription = t.label) },
label = { Text(t.label) },
)
Screen.Settings -> SettingsScreen(
}
}
},
) { innerPadding ->
Box(Modifier.fillMaxSize().padding(innerPadding)) {
when (tab) {
Tab.Connect -> ConnectScreen(settings = settings, onConnected = { streamHandle = it })
Tab.Settings -> SettingsScreen(
initial = settings,
onChange = { settings = it; settingsStore.save(it) },
onBack = { screen = Screen.Connect },
onBack = { tab = Tab.Connect },
)
is Screen.Stream -> StreamScreen(s.handle, onDisconnect = { screen = Screen.Connect })
}
}
}
}
}
@Composable
private fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit, onOpenSettings: () -> Unit) {
private fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
var host by remember { mutableStateOf("") }
@@ -351,7 +378,6 @@ private fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit, onOpe
enabled = !connecting && host.isNotBlank() && port.isNotBlank(),
onClick = { connect(host.trim(), port.toInt()) },
) { Text(if (connecting) "Connecting…" else "Connect ($w×$h@$hz)") }
TextButton(enabled = !connecting, onClick = onOpenSettings) { Text("Settings") }
status?.let {
Spacer(Modifier.height(12.dp))
Text(it, style = MaterialTheme.typography.bodySmall)