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
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:
@@ -59,6 +59,7 @@ dependencies {
|
|||||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||||
implementation("androidx.compose.foundation:foundation")
|
implementation("androidx.compose.foundation:foundation")
|
||||||
implementation("androidx.compose.material3:material3")
|
implementation("androidx.compose.material3:material3")
|
||||||
|
implementation("androidx.compose.material:material-icons-core") // bottom-bar tab icons
|
||||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||||
|
|
||||||
// Android TV components (we target phone + TV) land in the TV-UI milestone:
|
// 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.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Card
|
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.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.NavigationBar
|
||||||
|
import androidx.compose.material3.NavigationBarItem
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
@@ -48,6 +55,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.pointerInput
|
||||||
import androidx.compose.ui.input.pointer.positionChange
|
import androidx.compose.ui.input.pointer.positionChange
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
@@ -138,10 +146,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed interface Screen {
|
/** Bottom-bar destinations (the immersive stream view is shown full-screen, outside the bar). */
|
||||||
data object Connect : Screen
|
private enum class Tab(val label: String, val icon: ImageVector) {
|
||||||
data object Settings : Screen
|
Connect("Connect", Icons.Filled.Home),
|
||||||
data class Stream(val handle: Long) : Screen
|
Settings("Settings", Icons.Filled.Settings),
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -165,24 +173,43 @@ private fun App() {
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val settingsStore = remember { SettingsStore(context) }
|
val settingsStore = remember { SettingsStore(context) }
|
||||||
var settings by remember { mutableStateOf(settingsStore.load()) }
|
var settings by remember { mutableStateOf(settingsStore.load()) }
|
||||||
var screen by remember { mutableStateOf<Screen>(Screen.Connect) }
|
var streamHandle by remember { mutableStateOf(0L) } // 0 = not streaming
|
||||||
when (val s = screen) {
|
var tab by remember { mutableStateOf(Tab.Connect) }
|
||||||
Screen.Connect -> ConnectScreen(
|
|
||||||
settings = settings,
|
if (streamHandle != 0L) {
|
||||||
onConnected = { handle -> screen = Screen.Stream(handle) },
|
// Immersive: the stream takes the whole screen, no bottom bar.
|
||||||
onOpenSettings = { screen = Screen.Settings },
|
StreamScreen(streamHandle, onDisconnect = { streamHandle = 0L })
|
||||||
)
|
} else {
|
||||||
Screen.Settings -> SettingsScreen(
|
Scaffold(
|
||||||
initial = settings,
|
bottomBar = {
|
||||||
onChange = { settings = it; settingsStore.save(it) },
|
NavigationBar {
|
||||||
onBack = { screen = Screen.Connect },
|
Tab.entries.forEach { t ->
|
||||||
)
|
NavigationBarItem(
|
||||||
is Screen.Stream -> StreamScreen(s.handle, onDisconnect = { screen = Screen.Connect })
|
selected = tab == t,
|
||||||
|
onClick = { tab = t },
|
||||||
|
icon = { Icon(t.icon, contentDescription = t.label) },
|
||||||
|
label = { Text(t.label) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { 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 = { tab = Tab.Connect },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit, onOpenSettings: () -> Unit) {
|
private fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var host by remember { mutableStateOf("") }
|
var host by remember { mutableStateOf("") }
|
||||||
@@ -351,7 +378,6 @@ private fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit, onOpe
|
|||||||
enabled = !connecting && host.isNotBlank() && port.isNotBlank(),
|
enabled = !connecting && host.isNotBlank() && port.isNotBlank(),
|
||||||
onClick = { connect(host.trim(), port.toInt()) },
|
onClick = { connect(host.trim(), port.toInt()) },
|
||||||
) { Text(if (connecting) "Connecting…" else "Connect ($w×$h@$hz)") }
|
) { Text(if (connecting) "Connecting…" else "Connect ($w×$h@$hz)") }
|
||||||
TextButton(enabled = !connecting, onClick = onOpenSettings) { Text("Settings") }
|
|
||||||
status?.let {
|
status?.let {
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
Text(it, style = MaterialTheme.typography.bodySmall)
|
Text(it, style = MaterialTheme.typography.bodySmall)
|
||||||
|
|||||||
Reference in New Issue
Block a user