package iris import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import iris.data.SecureStore import iris.net.GatewayClient import iris.platform.setActiveController import iris.state.IrisController import iris.ui.ArtifactPreviewOverlay import iris.ui.screens.ChatScreen import iris.ui.screens.ConnectScreen import iris.ui.theme.IrisColors import iris.ui.theme.IrisTheme import iris.ui.theme.LocalUserTheme import iris.ui.theme.rememberBackgroundImage /** * Root composable shared by the Android and Desktop shells. * * M1: routes between the Connect screen (unpaired / auth failed) and the * Chat screen (paired). Later milestones add the channel list, search, * settings, and media (docs/10-android-app.md). */ @Composable fun IrisApp( store: SecureStore, deepLinkChatId: String? = null, deepLinkThreadId: String? = null, ) { val controller = remember(store) { IrisController(store) } DisposableEffect(controller) { setActiveController(controller) onDispose { setActiveController(null) controller.dispose() } } // M5: open the chat referenced by a push notification tap (applied now or // on the next hello.ack, whichever is later). LaunchedEffect(deepLinkChatId, deepLinkThreadId) { if (!deepLinkChatId.isNullOrBlank()) { controller.openDeepLink(deepLinkChatId, deepLinkThreadId) } } val state by controller.client.state.collectAsState() val theme by controller.theme.collectAsState() IrisTheme { CompositionLocalProvider(LocalUserTheme provides theme) { // The color goes through Surface's `color` parameter: a // Modifier.background on the Surface would be painted *under* the // Surface's own colorScheme.surface fill and never show through. // contentColor is pinned to the app's light text color — Surface // would otherwise derive it from the user's background (black // text on a light background), which would also darken text that // sits on the app's own dark panels. Surface( modifier = Modifier.fillMaxSize(), color = theme.background, contentColor = IrisColors.onBackground, ) { val bgImage = rememberBackgroundImage(theme) Box(modifier = Modifier.fillMaxSize()) { val bmp = bgImage if (bmp != null) { Image( bitmap = bmp, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), ) } val s = state when (s) { GatewayClient.State.Disconnected -> ConnectScreen(controller, prefillUrl = store.serverUrl, prefillToken = store.token) is GatewayClient.State.AuthFailed -> ConnectScreen( controller, prefillUrl = store.serverUrl, prefillToken = store.token, initialError = "Pairing rejected: ${s.message}", ) // Connecting / Reconnecting / Connected all render the chat; the header // status bubble + connection banner show the link state // without blocking the view (M7's full-screen spinner is gone). else -> ChatScreen(controller) } // M9: full-screen HTML artifact preview (opened from an // artifact card in the chat); covers everything while open. ArtifactPreviewOverlay() } } } } }