diff --git a/android/app/src/main/java/pt/naps/maestro/MainActivity.kt b/android/app/src/main/java/pt/naps/maestro/MainActivity.kt index c868e39..0b83d4a 100644 --- a/android/app/src/main/java/pt/naps/maestro/MainActivity.kt +++ b/android/app/src/main/java/pt/naps/maestro/MainActivity.kt @@ -4,49 +4,19 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent 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.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.structured.StructuredScreen -import pt.naps.maestro.ui.terminal.TerminalScreen +import pt.naps.maestro.ui.MaestroApp 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() { private lateinit var daemonLink: DaemonLink - @OptIn(ExperimentalMaterial3Api::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() @@ -57,59 +27,7 @@ class MainActivity : ComponentActivity() { setContent { MaestroTheme { - val linkState by daemonLink.state.collectAsState() - val baseUrl by prefs.baseUrl.collectAsState() - val drawerState = rememberDrawerState(DrawerValue.Closed) - val scope = rememberCoroutineScope() - var openSession by remember { mutableStateOf(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) - } - } - } + MaestroApp(daemonLink = daemonLink, prefs = prefs) } } } @@ -119,33 +37,3 @@ class MainActivity : ComponentActivity() { 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() - } -} diff --git a/android/app/src/main/java/pt/naps/maestro/ui/MaestroApp.kt b/android/app/src/main/java/pt/naps/maestro/ui/MaestroApp.kt new file mode 100644 index 0000000..dbec0fc --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/ui/MaestroApp.kt @@ -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, + ) + } +} diff --git a/android/app/src/main/java/pt/naps/maestro/ui/WelcomeScreen.kt b/android/app/src/main/java/pt/naps/maestro/ui/WelcomeScreen.kt new file mode 100644 index 0000000..f68e8ff --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/ui/WelcomeScreen.kt @@ -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 = {}) + } +}