Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8030b59f9 |
@@ -4,49 +4,19 @@ import android.os.Bundle
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.material3.DrawerValue
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.ModalDrawerSheet
|
|
||||||
import androidx.compose.material3.ModalNavigationDrawer
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.material3.rememberDrawerState
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import pt.naps.maestro.connection.DaemonLink
|
import pt.naps.maestro.connection.DaemonLink
|
||||||
import pt.naps.maestro.connection.DaemonPrefs
|
import pt.naps.maestro.connection.DaemonPrefs
|
||||||
import pt.naps.maestro.connection.LinkState
|
import pt.naps.maestro.ui.MaestroApp
|
||||||
import pt.naps.maestro.data.Session
|
|
||||||
import pt.naps.maestro.data.SessionView
|
|
||||||
import pt.naps.maestro.ui.sidebar.SidebarContent
|
|
||||||
import pt.naps.maestro.ui.sidebar.sessionName
|
|
||||||
import pt.naps.maestro.ui.structured.StructuredScreen
|
|
||||||
import pt.naps.maestro.ui.terminal.TerminalScreen
|
|
||||||
import pt.naps.maestro.ui.theme.MaestroTheme
|
import pt.naps.maestro.ui.theme.MaestroTheme
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single activity for the whole app (#268): owns the [DaemonLink] for its
|
||||||
|
* lifetime and hosts [MaestroApp], which carries the drawer, navigation and
|
||||||
|
* every pane.
|
||||||
|
*/
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
private lateinit var daemonLink: DaemonLink
|
private lateinit var daemonLink: DaemonLink
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
@@ -57,59 +27,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
MaestroTheme {
|
MaestroTheme {
|
||||||
val linkState by daemonLink.state.collectAsState()
|
MaestroApp(daemonLink = daemonLink, prefs = prefs)
|
||||||
val baseUrl by prefs.baseUrl.collectAsState()
|
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
var openSession by remember { mutableStateOf<Session?>(null) }
|
|
||||||
|
|
||||||
val session = openSession
|
|
||||||
if (session != null && session.view == SessionView.Structured) {
|
|
||||||
StructuredScreen(
|
|
||||||
daemonBaseUrl = baseUrl,
|
|
||||||
sessionId = session.id,
|
|
||||||
sessionTitle = sessionName(session),
|
|
||||||
onBack = { openSession = null },
|
|
||||||
)
|
|
||||||
} else if (session != null) {
|
|
||||||
TerminalScreen(
|
|
||||||
daemonBaseUrl = baseUrl,
|
|
||||||
sessionId = session.id,
|
|
||||||
sessionTitle = sessionName(session),
|
|
||||||
onBack = { openSession = null },
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
ModalNavigationDrawer(
|
|
||||||
drawerState = drawerState,
|
|
||||||
drawerContent = {
|
|
||||||
ModalDrawerSheet(modifier = Modifier.width(320.dp)) {
|
|
||||||
SidebarContent(
|
|
||||||
catalogStore = daemonLink.catalog,
|
|
||||||
onOpenSession = { s ->
|
|
||||||
openSession = s
|
|
||||||
scope.launch { drawerState.close() }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Scaffold(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
topBar = {
|
|
||||||
TopAppBar(
|
|
||||||
title = { Text("Maestro") },
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = { scope.launch { drawerState.open() } }) {
|
|
||||||
Text("☰")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) { innerPadding ->
|
|
||||||
PlaceholderScreen(modifier = Modifier.padding(innerPadding), linkState = linkState)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,33 +37,3 @@ class MainActivity : ComponentActivity() {
|
|||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun PlaceholderScreen(modifier: Modifier = Modifier, linkState: LinkState = LinkState.CONNECTING) {
|
|
||||||
Column(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(24.dp),
|
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Maestro",
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = R.string.placeholder_message),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "Daemon: ${linkState.name.lowercase()}",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
|
||||||
@Composable
|
|
||||||
private fun PlaceholderScreenPreview() {
|
|
||||||
MaestroTheme {
|
|
||||||
PlaceholderScreen()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
package pt.naps.maestro.ui
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.DrawerValue
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalDrawerSheet
|
||||||
|
import androidx.compose.material3.ModalNavigationDrawer
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.rememberDrawerState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.navigation.NavType
|
||||||
|
import androidx.navigation.compose.NavHost
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import androidx.navigation.navArgument
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import pt.naps.maestro.api.MaestroClient
|
||||||
|
import pt.naps.maestro.connection.DaemonLink
|
||||||
|
import pt.naps.maestro.connection.DaemonPrefs
|
||||||
|
import pt.naps.maestro.connection.LinkState
|
||||||
|
import pt.naps.maestro.data.Session
|
||||||
|
import pt.naps.maestro.data.SessionView
|
||||||
|
import pt.naps.maestro.ui.sidebar.SidebarContent
|
||||||
|
import pt.naps.maestro.ui.sidebar.sessionName
|
||||||
|
import pt.naps.maestro.ui.spawn.SpawnSheet
|
||||||
|
import pt.naps.maestro.ui.spawn.SpawnViewModel
|
||||||
|
import pt.naps.maestro.ui.structured.StructuredScreen
|
||||||
|
import pt.naps.maestro.ui.terminal.TerminalScreen
|
||||||
|
|
||||||
|
/** Route names for the single-activity nav graph (#268). */
|
||||||
|
private object Routes {
|
||||||
|
const val WELCOME = "welcome"
|
||||||
|
private const val SESSION_ID_ARG = "sessionId"
|
||||||
|
const val TERMINAL = "terminal/{$SESSION_ID_ARG}"
|
||||||
|
const val STRUCTURED = "structured/{$SESSION_ID_ARG}"
|
||||||
|
|
||||||
|
fun terminal(id: String) = "terminal/$id"
|
||||||
|
fun structured(id: String) = "structured/$id"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top-level app structure (#268): a persistent [ModalNavigationDrawer] around
|
||||||
|
* a single-back-stack [NavHost], tying together the sidebar (#264/#265), the
|
||||||
|
* terminal pane (#266), the structured pane (#267), and the spawn form
|
||||||
|
* (#269) that were each built standalone.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun MaestroApp(daemonLink: DaemonLink, prefs: DaemonPrefs) {
|
||||||
|
val navController = rememberNavController()
|
||||||
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val sessions by daemonLink.catalog.sessions.collectAsState()
|
||||||
|
val linkState by daemonLink.state.collectAsState()
|
||||||
|
val baseUrl by prefs.baseUrl.collectAsState()
|
||||||
|
var showSpawnSheet by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val infoClient = remember(baseUrl) { MaestroClient(baseUrl) }
|
||||||
|
var hostname by remember(baseUrl) { mutableStateOf("") }
|
||||||
|
LaunchedEffect(linkState, infoClient) {
|
||||||
|
if (linkState == LinkState.ONLINE) {
|
||||||
|
hostname = runCatching { infoClient.getDaemonInfo().hostname }.getOrDefault(hostname)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openSession(session: Session) {
|
||||||
|
val route = if (session.view == SessionView.Structured) {
|
||||||
|
Routes.structured(session.id)
|
||||||
|
} else {
|
||||||
|
Routes.terminal(session.id)
|
||||||
|
}
|
||||||
|
navController.navigate(route) {
|
||||||
|
popUpTo(Routes.WELCOME)
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
ModalNavigationDrawer(
|
||||||
|
drawerState = drawerState,
|
||||||
|
drawerContent = {
|
||||||
|
ModalDrawerSheet(modifier = Modifier.width(320.dp)) {
|
||||||
|
SidebarContent(
|
||||||
|
catalogStore = daemonLink.catalog,
|
||||||
|
onOpenSession = ::openSession,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
ConnectionStatusBar(linkState = linkState, hostname = hostname)
|
||||||
|
NavHost(navController = navController, startDestination = Routes.WELCOME) {
|
||||||
|
composable(Routes.WELCOME) {
|
||||||
|
WelcomeScreen(
|
||||||
|
onOpenSessions = { scope.launch { drawerState.open() } },
|
||||||
|
onSpawnSession = { showSpawnSheet = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(
|
||||||
|
Routes.TERMINAL,
|
||||||
|
arguments = listOf(navArgument("sessionId") { type = NavType.StringType }),
|
||||||
|
) { backStackEntry ->
|
||||||
|
val id = backStackEntry.arguments?.getString("sessionId") ?: return@composable
|
||||||
|
TerminalScreen(
|
||||||
|
daemonBaseUrl = baseUrl,
|
||||||
|
sessionId = id,
|
||||||
|
sessionTitle = sessions[id]?.let(::sessionName) ?: id,
|
||||||
|
onBack = { navController.popBackStack(Routes.WELCOME, inclusive = false) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(
|
||||||
|
Routes.STRUCTURED,
|
||||||
|
arguments = listOf(navArgument("sessionId") { type = NavType.StringType }),
|
||||||
|
) { backStackEntry ->
|
||||||
|
val id = backStackEntry.arguments?.getString("sessionId") ?: return@composable
|
||||||
|
StructuredScreen(
|
||||||
|
daemonBaseUrl = baseUrl,
|
||||||
|
sessionId = id,
|
||||||
|
sessionTitle = sessions[id]?.let(::sessionName) ?: id,
|
||||||
|
onBack = { navController.popBackStack(Routes.WELCOME, inclusive = false) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showSpawnSheet) {
|
||||||
|
val viewModel = remember(infoClient) { SpawnViewModel(infoClient) }
|
||||||
|
SpawnSheet(
|
||||||
|
viewModel = viewModel,
|
||||||
|
onDismiss = { showSpawnSheet = false },
|
||||||
|
onSpawned = { session ->
|
||||||
|
showSpawnSheet = false
|
||||||
|
openSession(session)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Declared last so it sits above the NavHost's own back handling on the
|
||||||
|
// dispatcher stack: a drawer open over a session must close first, not
|
||||||
|
// pop the session back to Welcome.
|
||||||
|
BackHandler(enabled = drawerState.isOpen) {
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Connection indicator (green online / red down) plus the daemon hostname
|
||||||
|
* from `/api/info`, visible above every route. */
|
||||||
|
@Composable
|
||||||
|
private fun ConnectionStatusBar(linkState: LinkState, hostname: String) {
|
||||||
|
val online = linkState == LinkState.ONLINE
|
||||||
|
val dotColor = if (online) Color(0xFF2E7D32) else Color(0xFFC62828)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(8.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(dotColor),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = hostname.ifEmpty { linkState.name.lowercase() },
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package pt.naps.maestro.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
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.material3.Button
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import pt.naps.maestro.ui.theme.MaestroTheme
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shown when no session is open (#268): the app's resting state, reached at
|
||||||
|
* launch and every time a session closes or the operator backs out of one.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun WelcomeScreen(
|
||||||
|
onOpenSessions: () -> Unit,
|
||||||
|
onSpawnSession: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(24.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(text = "Maestro", style = MaterialTheme.typography.displaySmall)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "No session open",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(32.dp))
|
||||||
|
Button(onClick = onOpenSessions, modifier = Modifier.fillMaxWidth(0.7f)) {
|
||||||
|
Text("Sessions")
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
OutlinedButton(onClick = onSpawnSession, modifier = Modifier.fillMaxWidth(0.7f)) {
|
||||||
|
Text("Spawn session")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun WelcomeScreenPreview() {
|
||||||
|
MaestroTheme {
|
||||||
|
WelcomeScreen(onOpenSessions = {}, onSpawnSession = {})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user