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.key import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Density 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 import iris.util.PairLink /** * Root composable shared by the Android and Desktop shells. * * Routes between the Connect screen (unpaired / auth failed) and the main * app (paired), which hosts the channel list, chat, search, settings, and * media (docs/10-android-app.md). */ @Composable fun IrisApp( store: SecureStore, deepLinkChatId: String? = null, deepLinkThreadId: String? = null, deepLinkPair: PairLink? = 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() val fontScale by controller.fontSizeScale.collectAsState() val baseDensity = LocalDensity.current IrisTheme { // Font-size setting (Settings → Appearance): a multiplier on top of the // system font scale. fontScale affects sp text only — dp layout is // untouched, so the UI keeps its proportions at any size. CompositionLocalProvider( LocalUserTheme provides theme, LocalDensity provides Density( density = baseDensity.density, fontScale = baseDensity.fontScale * fontScale, ), ) { // 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 // QR pairing (docs/20): an iris://pair deep link prefills // the Connect screen, taking priority over stored creds. val pairUrl = deepLinkPair?.url ?: store.serverUrl val pairToken = deepLinkPair?.token ?: store.token when (s) { GatewayClient.State.Disconnected -> { key(deepLinkPair) { ConnectScreen(controller, prefillUrl = pairUrl, prefillToken = pairToken) } } is GatewayClient.State.AuthFailed -> { key(deepLinkPair) { ConnectScreen( controller, prefillUrl = pairUrl, prefillToken = pairToken, 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() } } } } }