On a phone the thread list is home, each screen has a top bar over an edge-to-edge sheet, and Settings opens on a hub of grouped pages. Threads show as two-line rows on raised cards with the computer they run on, and the paired computers' connections sit at the top of home. Tablets keep the desktop layout. Also: re-pairing with a corrected key reconnects at once, and the tablet sidebar's cards keep their host and agent marks at the end of the row. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Showing 16 changed files with 1044 additions and 225 deletions.
| @@ -12,11 +12,16 @@ the phone, scan the QR code shown under **Pairing code**, or paste the code | ||
| 12 | 12 | key). A `vectrum://…` link pairs too. The phone tries the addresses fastest first (the LAN, then |
| 13 | 13 | Tailscale, then another VPN), keeps the ones the computer reports as it moves between |
| 14 | 14 | networks, and falls back on the next one when an address doesn't answer. Several computers can be paired; |
| 15 | - their threads share the sidebar, tagged with each one's name. The connection isn't encrypted, | |
| 15 | + their threads share one list, tagged with each one's name. The connection isn't encrypted, | |
| 16 | 16 | so pair on networks you trust or over a VPN such as Tailscale or WireGuard. |
| 17 | 17 | |
| 18 | 18 | ## What it has |
| 19 | 19 | |
| 20 | + On a phone the app is laid out as phone apps are: the thread list is home, and each screen has | |
| 21 | + a top bar (back, its title, its actions) over one raised sheet that runs to the screen's edges. | |
| 22 | + Settings open on a hub of grouped pages. A tablet keeps the desktop's layout: the sidebar beside | |
| 23 | + an inset sheet. | |
| 24 | + | |
| 20 | 25 | - The sidebar's inbox: pinned and active threads, the Working, Snoozed and Settled shelves, |
| 21 | 26 | search, the project filter, and the thread menu on a long press (pin, settle, snooze, wake, |
| 22 | 27 | rename, copy, delete) with the desktop's undo notices. |
| @@ -56,7 +61,7 @@ JDK 21 and the Android SDK (platform 37) are needed; point `local.properties` at | ||
| 56 | 61 | (`cargo test export_android_themes -- --ignored`), so every theme matches it exactly; |
| 57 | 62 | `Metal.kt` has the surfaces (raised, well, plate, with the desktop's layered shadows), caps, |
| 58 | 63 | switches, LEDs, keycaps and engraved labels. |
| 59 | - - `ui/`: `sidebar/`, `chat/` (header, transcript, Markdown), `composer/` (composer, pickers, | |
| 64 | + - `ui/`: `Phone.kt` (the phone's bar and sheet), `sidebar/` (also the phone's home), `chat/` (header, transcript, Markdown), `composer/` (composer, pickers, | |
| 60 | 65 | dock, start screen), `diff/`, `settings/`, `pair/` (pairing, adding projects), `panels/` |
| 61 | 66 | (right panel, subagents, command palette), `terminal/` and `device/`. |
| 62 | 67 | - `term/`: the terminal emulator the terminal panel draws. |
| @@ -127,8 +127,10 @@ class AppState(context: Context) { | ||
| 127 | 127 | val address = wanted.joinToString(",") |
| 128 | 128 | val existing = hosts.find { host -> host.saved.addresses.any { it in wanted } } |
| 129 | 129 | if (existing != null) { |
| 130 | + val rekeyed = existing.saved.key != key | |
| 130 | 131 | existing.update(existing.saved.copy(address = address, key = key, enabled = true)) |
| 131 | - existing.retry() | |
| 132 | + // A connection keeps the key it was made with; a new key needs a new one. | |
| 133 | + if (rekeyed) existing.connect(deviceName) else existing.retry() | |
| 132 | 134 | return existing |
| 133 | 135 | } |
| 134 | 136 | val host = HostState(SavedHost(name = name, address = address, key = key), this) |
| @@ -325,6 +325,9 @@ enum class CapKind { | ||
| 325 | 325 | } |
| 326 | 326 | |
| 327 | 327 | enum class CapSize(val height: Dp, val pad: Dp, val font: TextUnit, val glyph: Dp) { |
| 328 | + /** 44dp, a phone's touch size: its top bar and the screen's main action. */ | |
| 329 | + Large(44.dp, 18.dp, 15.sp, 21.dp), | |
| 330 | + | |
| 328 | 331 | /** 32px, MetalUI's default button. */ |
| 329 | 332 | Regular(32.dp, 15.dp, 13.sp, 16.dp), |
| 330 | 333 | |
| @@ -0,0 +1,142 @@ | ||
| 1 | + package cc.vectrum.code.ui | |
| 2 | + | |
| 3 | + import androidx.compose.foundation.layout.Arrangement | |
| 4 | + import androidx.compose.foundation.layout.Box | |
| 5 | + import androidx.compose.foundation.layout.Column | |
| 6 | + import androidx.compose.foundation.layout.ColumnScope | |
| 7 | + import androidx.compose.foundation.layout.Row | |
| 8 | + import androidx.compose.foundation.layout.RowScope | |
| 9 | + import androidx.compose.foundation.layout.Spacer | |
| 10 | + import androidx.compose.foundation.layout.WindowInsets | |
| 11 | + import androidx.compose.foundation.layout.WindowInsetsSides | |
| 12 | + import androidx.compose.foundation.layout.fillMaxSize | |
| 13 | + import androidx.compose.foundation.layout.fillMaxWidth | |
| 14 | + import androidx.compose.foundation.layout.height | |
| 15 | + import androidx.compose.foundation.layout.only | |
| 16 | + import androidx.compose.foundation.layout.padding | |
| 17 | + import androidx.compose.foundation.layout.safeDrawing | |
| 18 | + import androidx.compose.foundation.layout.size | |
| 19 | + import androidx.compose.foundation.layout.widthIn | |
| 20 | + import androidx.compose.foundation.layout.windowInsetsPadding | |
| 21 | + import androidx.compose.foundation.shape.RoundedCornerShape | |
| 22 | + import androidx.compose.runtime.Composable | |
| 23 | + import androidx.compose.runtime.staticCompositionLocalOf | |
| 24 | + import androidx.compose.ui.Alignment | |
| 25 | + import androidx.compose.ui.Modifier | |
| 26 | + import androidx.compose.ui.draw.clip | |
| 27 | + import androidx.compose.ui.text.TextStyle | |
| 28 | + import androidx.compose.ui.text.font.FontWeight | |
| 29 | + import androidx.compose.ui.text.style.TextAlign | |
| 30 | + import androidx.compose.ui.unit.dp | |
| 31 | + import androidx.compose.ui.unit.sp | |
| 32 | + import cc.vectrum.code.theme.Cap | |
| 33 | + import cc.vectrum.code.theme.CapKind | |
| 34 | + import cc.vectrum.code.theme.CapSize | |
| 35 | + import cc.vectrum.code.theme.Txt | |
| 36 | + import cc.vectrum.code.theme.palette | |
| 37 | + import cc.vectrum.code.theme.plate | |
| 38 | + | |
| 39 | + // The phone's layout, as phone apps lay theirs out: a top bar on the page | |
| 40 | + // with the way back, the screen's title and its actions, over one raised | |
| 41 | + // sheet that runs to the screen's edges and bottom. A tablet keeps the | |
| 42 | + // desktop's layout instead: the sidebar beside an inset sheet. | |
| 43 | + | |
| 44 | + /** Whether the app has a phone's layout: narrower than a tablet's 840dp. */ | |
| 45 | + val LocalPhone = staticCompositionLocalOf { false } | |
| 46 | + | |
| 47 | + /** The phone sheet's top corners. */ | |
| 48 | + val PhoneSheet = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp) | |
| 49 | + | |
| 50 | + /** A phone screen: [bar] on the page, then [content] on the sheet. */ | |
| 51 | + @Composable | |
| 52 | + fun PhoneScaffold( | |
| 53 | + bar: @Composable RowScope.() -> Unit, | |
| 54 | + modifier: Modifier = Modifier, | |
| 55 | + content: @Composable ColumnScope.() -> Unit, | |
| 56 | + ) { | |
| 57 | + val p = palette | |
| 58 | + Column(modifier.fillMaxSize()) { | |
| 59 | + Row( | |
| 60 | + Modifier.fillMaxWidth().height(64.dp).padding(start = 8.dp, end = 10.dp), | |
| 61 | + horizontalArrangement = Arrangement.spacedBy(4.dp), | |
| 62 | + verticalAlignment = Alignment.CenterVertically, | |
| 63 | + content = bar, | |
| 64 | + ) | |
| 65 | + Column( | |
| 66 | + Modifier | |
| 67 | + .weight(1f) | |
| 68 | + .fillMaxWidth() | |
| 69 | + .plate(p, PhoneSheet) | |
| 70 | + .clip(PhoneSheet) | |
| 71 | + // The sheet runs under the navigation bar; what's on it stays above it and the keyboard. | |
| 72 | + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)), | |
| 73 | + content = content, | |
| 74 | + ) | |
| 75 | + } | |
| 76 | + } | |
| 77 | + | |
| 78 | + /** The bar's way back. */ | |
| 79 | + @Composable | |
| 80 | + fun BarBack(onClick: () -> Unit) { | |
| 81 | + Cap(icon = Lucide.ArrowLeft, kind = CapKind.Ghost, size = CapSize.Large, iconColor = palette.ink, onClick = onClick) | |
| 82 | + } | |
| 83 | + | |
| 84 | + /** A bar's action. */ | |
| 85 | + @Composable | |
| 86 | + fun BarAction(icon: Lucide, enabled: Boolean = true, selected: Boolean = false, onClick: () -> Unit) { | |
| 87 | + Cap( | |
| 88 | + icon = icon, | |
| 89 | + kind = CapKind.Ghost, | |
| 90 | + size = CapSize.Large, | |
| 91 | + iconColor = palette.ink, | |
| 92 | + enabled = enabled, | |
| 93 | + selected = selected, | |
| 94 | + onClick = onClick, | |
| 95 | + ) | |
| 96 | + } | |
| 97 | + | |
| 98 | + /** A screen's title in the bar. */ | |
| 99 | + @Composable | |
| 100 | + fun RowScope.BarTitle(title: String) { | |
| 101 | + Txt( | |
| 102 | + title, | |
| 103 | + Modifier.weight(1f).padding(start = 6.dp), | |
| 104 | + color = palette.ink, | |
| 105 | + size = 22.sp, | |
| 106 | + weight = FontWeight.SemiBold, | |
| 107 | + maxLines = 1, | |
| 108 | + ) | |
| 109 | + } | |
| 110 | + | |
| 111 | + /** A screen with nothing on it yet: a title, what to do about it, and the action that does it. */ | |
| 112 | + @Composable | |
| 113 | + fun EmptyState(title: String, message: String, modifier: Modifier = Modifier, action: (@Composable () -> Unit)? = null) { | |
| 114 | + val p = palette | |
| 115 | + Box(modifier.fillMaxSize().padding(horizontal = 32.dp), contentAlignment = Alignment.Center) { | |
| 116 | + Column( | |
| 117 | + Modifier.widthIn(max = 360.dp).padding(bottom = 48.dp), | |
| 118 | + horizontalAlignment = Alignment.CenterHorizontally, | |
| 119 | + ) { | |
| 120 | + Txt( | |
| 121 | + title, | |
| 122 | + color = p.ink, | |
| 123 | + size = 22.sp, | |
| 124 | + weight = FontWeight.SemiBold, | |
| 125 | + lineHeight = 28.sp, | |
| 126 | + style = TextStyle(textAlign = TextAlign.Center), | |
| 127 | + ) | |
| 128 | + Spacer(Modifier.size(8.dp)) | |
| 129 | + Txt( | |
| 130 | + message, | |
| 131 | + color = p.ink3, | |
| 132 | + size = 15.sp, | |
| 133 | + lineHeight = 22.sp, | |
| 134 | + style = TextStyle(textAlign = TextAlign.Center), | |
| 135 | + ) | |
| 136 | + if (action != null) { | |
| 137 | + Spacer(Modifier.size(24.dp)) | |
| 138 | + action() | |
| 139 | + } | |
| 140 | + } | |
| 141 | + } | |
| 142 | + } | |
| @@ -2,15 +2,12 @@ package cc.vectrum.code.ui | ||
| 2 | 2 | |
| 3 | 3 | import androidx.activity.compose.BackHandler |
| 4 | 4 | import androidx.compose.animation.AnimatedVisibility |
| 5 | - import androidx.compose.animation.core.animateDpAsState | |
| 6 | - import androidx.compose.animation.core.animateFloatAsState | |
| 7 | 5 | import androidx.compose.animation.fadeIn |
| 8 | 6 | import androidx.compose.animation.fadeOut |
| 9 | 7 | import androidx.compose.animation.slideInVertically |
| 10 | 8 | import androidx.compose.animation.slideOutVertically |
| 11 | 9 | import androidx.compose.foundation.background |
| 12 | 10 | import androidx.compose.foundation.clickable |
| 13 | - import androidx.compose.foundation.gestures.detectHorizontalDragGestures | |
| 14 | 11 | import androidx.compose.foundation.layout.Arrangement |
| 15 | 12 | import androidx.compose.foundation.layout.Box |
| 16 | 13 | import androidx.compose.foundation.layout.BoxWithConstraints |
| @@ -20,21 +17,20 @@ import androidx.compose.foundation.layout.fillMaxHeight | ||
| 20 | 17 | import androidx.compose.foundation.layout.fillMaxSize |
| 21 | 18 | import androidx.compose.foundation.layout.fillMaxWidth |
| 22 | 19 | import androidx.compose.foundation.layout.navigationBarsPadding |
| 23 | - import androidx.compose.foundation.layout.offset | |
| 24 | 20 | import androidx.compose.foundation.layout.padding |
| 21 | + import androidx.compose.foundation.layout.WindowInsetsSides | |
| 22 | + import androidx.compose.foundation.layout.only | |
| 25 | 23 | import androidx.compose.foundation.layout.safeDrawing |
| 26 | 24 | import androidx.compose.foundation.layout.width |
| 27 | 25 | import androidx.compose.foundation.layout.windowInsetsPadding |
| 28 | 26 | import androidx.compose.foundation.shape.RoundedCornerShape |
| 29 | 27 | import androidx.compose.runtime.Composable |
| 28 | + import androidx.compose.runtime.CompositionLocalProvider | |
| 30 | 29 | import androidx.compose.runtime.LaunchedEffect |
| 31 | - import androidx.compose.runtime.getValue | |
| 32 | 30 | import androidx.compose.ui.Alignment |
| 33 | 31 | import androidx.compose.ui.Modifier |
| 34 | - import androidx.compose.ui.draw.alpha | |
| 35 | 32 | import androidx.compose.ui.draw.clip |
| 36 | - import androidx.compose.ui.graphics.Color | |
| 37 | - import androidx.compose.ui.input.pointer.pointerInput | |
| 33 | + import androidx.compose.ui.graphics.Shape | |
| 38 | 34 | import androidx.compose.ui.unit.dp |
| 39 | 35 | import androidx.compose.ui.unit.sp |
| 40 | 36 | import cc.vectrum.code.data.AppState |
| @@ -52,6 +48,7 @@ import cc.vectrum.code.ui.pair.PairScreen | ||
| 52 | 48 | import cc.vectrum.code.ui.panels.CommandPalette |
| 53 | 49 | import cc.vectrum.code.ui.panels.RightPanel |
| 54 | 50 | import cc.vectrum.code.ui.settings.SettingsScreen |
| 51 | + import cc.vectrum.code.ui.sidebar.HomeScreen | |
| 55 | 52 | import cc.vectrum.code.ui.sidebar.Sidebar |
| 56 | 53 | import kotlinx.coroutines.delay |
| 57 | 54 | |
| @@ -61,9 +58,10 @@ val SheetRadius = 24.dp | ||
| 61 | 58 | val SidebarWidth = 280.dp |
| 62 | 59 | |
| 63 | 60 | /** |
| 64 | - * The window, as the desktop lays it out: the sidebar on the page, and the | |
| 65 | - * conversation on a raised sheet beside it. On a phone the sidebar is a | |
| 66 | - * drawer that slides over the sheet; on a tablet it sits beside it. | |
| 61 | + * The window. On a tablet, as the desktop lays it out: the sidebar on the | |
| 62 | + * page, and the conversation on a raised sheet beside it. On a phone, as | |
| 63 | + * phone apps do: the thread list is home, and each screen has a top bar | |
| 64 | + * over a sheet that runs to the screen's edges (see [PhoneScaffold]). | |
| 67 | 65 | */ |
| 68 | 66 | @Composable |
| 69 | 67 | fun Root(app: AppState, nav: Nav) { |
| @@ -73,80 +71,63 @@ fun Root(app: AppState, nav: Nav) { | ||
| 73 | 71 | Modifier |
| 74 | 72 | .fillMaxSize() |
| 75 | 73 | .background(p.page) |
| 76 | - .windowInsetsPadding(WindowInsets.safeDrawing), | |
| 74 | + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)), | |
| 77 | 75 | ) { |
| 78 | 76 | val wide = maxWidth >= 840.dp |
| 79 | - val drawerWidth = minOf(SidebarWidth + 24.dp, maxWidth - 48.dp) | |
| 80 | - if (wide) { | |
| 81 | - Row(Modifier.fillMaxSize()) { | |
| 82 | - Sidebar(app, nav, Modifier.width(SidebarWidth).fillMaxHeight()) | |
| 83 | - Sheet(app, nav, Modifier.weight(1f).padding(top = SheetInset, end = SheetInset, bottom = SheetInset)) | |
| 84 | - } | |
| 85 | - } else { | |
| 86 | - val shift by animateDpAsState(if (nav.sidebarOpen) drawerWidth else 0.dp, label = "drawer") | |
| 87 | - val scrim by animateFloatAsState(if (nav.sidebarOpen) 1f else 0f, label = "scrim") | |
| 88 | - Box( | |
| 89 | - Modifier | |
| 90 | - .fillMaxSize() | |
| 91 | - .pointerInput(Unit) { | |
| 92 | - // A swipe from the left edge opens the sidebar, as on most phone apps. | |
| 93 | - var start = 0f | |
| 94 | - var total = 0f | |
| 95 | - detectHorizontalDragGestures( | |
| 96 | - onDragStart = { start = it.x; total = 0f }, | |
| 97 | - onHorizontalDrag = { _, amount -> total += amount }, | |
| 98 | - onDragEnd = { | |
| 99 | - if (start < 32.dp.toPx() && total > 60.dp.toPx()) nav.sidebarOpen = true | |
| 100 | - if (nav.sidebarOpen && total < -60.dp.toPx()) nav.sidebarOpen = false | |
| 101 | - }, | |
| 102 | - ) | |
| 103 | - }, | |
| 104 | - ) { | |
| 105 | - Sidebar(app, nav, Modifier.width(drawerWidth).fillMaxHeight().offset(x = shift - drawerWidth)) | |
| 106 | - Box(Modifier.fillMaxSize().offset(x = shift)) { | |
| 107 | - Sheet(app, nav, Modifier.fillMaxSize().padding(SheetInset)) | |
| 108 | - if (scrim > 0f) { | |
| 109 | - Box( | |
| 110 | - Modifier | |
| 111 | - .fillMaxSize() | |
| 112 | - .alpha(scrim) | |
| 113 | - .background(Color.Black.copy(alpha = if (p.dark) 0.3f else 0.08f)) | |
| 114 | - .clickable(interactionSource = null, indication = null) { nav.sidebarOpen = false }, | |
| 115 | - ) | |
| 77 | + CompositionLocalProvider(LocalPhone provides !wide) { | |
| 78 | + if (wide) { | |
| 79 | + Row(Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom))) { | |
| 80 | + Sidebar(app, nav, Modifier.width(SidebarWidth).fillMaxHeight()) | |
| 81 | + val shape = RoundedCornerShape(SheetRadius) | |
| 82 | + Box(Modifier.weight(1f).padding(top = SheetInset, end = SheetInset, bottom = SheetInset).plate(p, shape).clip(shape)) { | |
| 83 | + Screens(app, nav) | |
| 84 | + Panel(app, nav, Modifier.fillMaxSize()) | |
| 116 | 85 | } |
| 117 | 86 | } |
| 87 | + } else { | |
| 88 | + Screens(app, nav) | |
| 89 | + // The panel covers the sheet, under the thread's bar. | |
| 90 | + Panel(app, nav, Modifier.fillMaxSize().padding(top = 64.dp), PhoneSheet) | |
| 91 | + } | |
| 92 | + Box(Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom))) { | |
| 93 | + if (nav.palette) CommandPalette(app, nav) | |
| 94 | + NoticeBar(app, Modifier.align(Alignment.BottomCenter)) | |
| 118 | 95 | } |
| 119 | 96 | } |
| 120 | - if (nav.palette) CommandPalette(app, nav) | |
| 121 | - NoticeBar(app, Modifier.align(Alignment.BottomCenter)) | |
| 122 | 97 | } |
| 123 | 98 | } |
| 124 | 99 | |
| 125 | - /** The raised sheet and what's on it. */ | |
| 100 | + /** What's on the sheet. */ | |
| 126 | 101 | @Composable |
| 127 | - private fun Sheet(app: AppState, nav: Nav, modifier: Modifier) { | |
| 128 | - val p = palette | |
| 129 | - val shape = RoundedCornerShape(SheetRadius) | |
| 130 | - Box(modifier.plate(p, shape).clip(shape)) { | |
| 131 | - when (val screen = nav.screen) { | |
| 132 | - Screen.Home -> if (app.hosts.isEmpty()) PairScreen(app, nav) else NewThreadScreen(app, nav, Screen.NewThread()) | |
| 133 | - is Screen.Thread -> { | |
| 134 | - val host = app.host(screen.ref.host) | |
| 135 | - if (host == null) { | |
| 136 | - LaunchedEffect(screen) { nav.home() } | |
| 137 | - } else { | |
| 138 | - ThreadScreen(app, nav, host, screen.ref.thread) | |
| 139 | - } | |
| 140 | - } | |
| 141 | - is Screen.NewThread -> NewThreadScreen(app, nav, screen) | |
| 142 | - is Screen.Settings -> SettingsScreen(app, nav, screen.page) | |
| 143 | - Screen.Pair -> PairScreen(app, nav) | |
| 144 | - is Screen.Diff -> DiffScreen(app, nav, screen) | |
| 102 | + private fun Screens(app: AppState, nav: Nav) { | |
| 103 | + val phone = LocalPhone.current | |
| 104 | + when (val screen = nav.screen) { | |
| 105 | + Screen.Home -> when { | |
| 106 | + phone -> HomeScreen(app, nav) | |
| 107 | + app.hosts.isEmpty() -> PairScreen(app, nav) | |
| 108 | + else -> NewThreadScreen(app, nav, Screen.NewThread()) | |
| 145 | 109 | } |
| 146 | - if (nav.surface != null || nav.surfacePicker) { | |
| 147 | - val screen = nav.screen | |
| 148 | - if (screen is Screen.Thread) RightPanel(app, nav, screen.ref) | |
| 110 | + is Screen.Thread -> { | |
| 111 | + val host = app.host(screen.ref.host) | |
| 112 | + if (host == null) { | |
| 113 | + LaunchedEffect(screen) { nav.home() } | |
| 114 | + } else { | |
| 115 | + ThreadScreen(app, nav, host, screen.ref.thread) | |
| 116 | + } | |
| 149 | 117 | } |
| 118 | + is Screen.NewThread -> NewThreadScreen(app, nav, screen) | |
| 119 | + is Screen.Settings -> SettingsScreen(app, nav, screen.page) | |
| 120 | + Screen.Pair -> PairScreen(app, nav) | |
| 121 | + is Screen.Diff -> DiffScreen(app, nav, screen) | |
| 122 | + } | |
| 123 | + } | |
| 124 | + | |
| 125 | + /** The right panel over a thread, while one's open. */ | |
| 126 | + @Composable | |
| 127 | + private fun Panel(app: AppState, nav: Nav, modifier: Modifier, shape: Shape = RoundedCornerShape(SheetRadius)) { | |
| 128 | + val screen = nav.screen | |
| 129 | + if ((nav.surface != null || nav.surfacePicker) && screen is Screen.Thread) { | |
| 130 | + Box(modifier) { RightPanel(app, nav, screen.ref, shape) } | |
| 150 | 131 | } |
| 151 | 132 | } |
| 152 | 133 | |
| @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Arrangement | ||
| 6 | 6 | import androidx.compose.foundation.layout.Box |
| 7 | 7 | import androidx.compose.foundation.layout.Column |
| 8 | 8 | import androidx.compose.foundation.layout.Row |
| 9 | + import androidx.compose.foundation.layout.RowScope | |
| 9 | 10 | import androidx.compose.foundation.layout.fillMaxWidth |
| 10 | 11 | import androidx.compose.foundation.layout.height |
| 11 | 12 | import androidx.compose.foundation.layout.padding |
| @@ -20,7 +21,6 @@ import androidx.compose.runtime.setValue | ||
| 20 | 21 | import androidx.compose.ui.Alignment |
| 21 | 22 | import androidx.compose.ui.Modifier |
| 22 | 23 | import androidx.compose.ui.draw.clip |
| 23 | - import androidx.compose.ui.platform.LocalConfiguration | |
| 24 | 24 | import androidx.compose.ui.platform.LocalContext |
| 25 | 25 | import androidx.compose.ui.text.font.FontWeight |
| 26 | 26 | import androidx.compose.ui.unit.dp |
| @@ -38,6 +38,8 @@ import cc.vectrum.code.theme.StatusLed | ||
| 38 | 38 | import cc.vectrum.code.theme.Tag |
| 39 | 39 | import cc.vectrum.code.theme.Txt |
| 40 | 40 | import cc.vectrum.code.theme.palette |
| 41 | + import cc.vectrum.code.ui.BarAction | |
| 42 | + import cc.vectrum.code.ui.BarBack | |
| 41 | 43 | import cc.vectrum.code.ui.Lucide |
| 42 | 44 | import cc.vectrum.code.ui.LucideIcon |
| 43 | 45 | import cc.vectrum.code.ui.Nav |
| @@ -56,8 +58,6 @@ import cc.vectrum.code.util.tintFor | ||
| 56 | 58 | fun ChatHeader(app: AppState, nav: Nav, thread: OpenThread) { |
| 57 | 59 | val p = palette |
| 58 | 60 | val context = LocalContext.current |
| 59 | - // On a tablet the sidebar sits beside the sheet, as Root lays it out. | |
| 60 | - val drawer = LocalConfiguration.current.screenWidthDp < 840 | |
| 61 | 61 | val summary = thread.summary |
| 62 | 62 | val host = thread.host |
| 63 | 63 | val projectName = thread.project?.name ?: summary.project.trimEnd('/').substringAfterLast('/') |
| @@ -70,11 +70,10 @@ fun ChatHeader(app: AppState, nav: Nav, thread: OpenThread) { | ||
| 70 | 70 | Modifier |
| 71 | 71 | .fillMaxWidth() |
| 72 | 72 | .height(52.dp) |
| 73 | - .padding(start = if (drawer) 8.dp else 20.dp, end = 8.dp), | |
| 73 | + .padding(start = 20.dp, end = 8.dp), | |
| 74 | 74 | horizontalArrangement = Arrangement.spacedBy(8.dp), |
| 75 | 75 | verticalAlignment = Alignment.CenterVertically, |
| 76 | 76 | ) { |
| 77 | - if (drawer) Cap(icon = Lucide.PanelLeftOpen, kind = CapKind.Ghost) { nav.sidebarOpen = true } | |
| 78 | 77 | if (projectName.isNotEmpty()) { |
| 79 | 78 | // "New thread in {project}". |
| 80 | 79 | Row( |
| @@ -90,7 +89,7 @@ fun ChatHeader(app: AppState, nav: Nav, thread: OpenThread) { | ||
| 90 | 89 | if (parent == null) { |
| 91 | 90 | Txt( |
| 92 | 91 | projectName, |
| 93 | - Modifier.widthIn(max = if (drawer) 96.dp else 160.dp), | |
| 92 | + Modifier.widthIn(max = 160.dp), | |
| 94 | 93 | color = p.ink3, |
| 95 | 94 | size = 14.sp, |
| 96 | 95 | weight = FontWeight.Medium, |
| @@ -110,7 +109,7 @@ fun ChatHeader(app: AppState, nav: Nav, thread: OpenThread) { | ||
| 110 | 109 | horizontalArrangement = Arrangement.spacedBy(6.dp), |
| 111 | 110 | verticalAlignment = Alignment.CenterVertically, |
| 112 | 111 | ) { |
| 113 | - Txt(parent.title, Modifier.widthIn(max = if (drawer) 88.dp else 200.dp), color = p.ink3, size = 14.sp, maxLines = 1) | |
| 112 | + Txt(parent.title, Modifier.widthIn(max = 200.dp), color = p.ink3, size = 14.sp, maxLines = 1) | |
| 114 | 113 | Txt("/", color = p.icon, size = 14.sp) |
| 115 | 114 | LucideIcon(Lucide.Bot, size = 14.dp, tint = p.ink3) |
| 116 | 115 | } |
| @@ -156,6 +155,69 @@ fun ChatHeader(app: AppState, nav: Nav, thread: OpenThread) { | ||
| 156 | 155 | ThreadMenu(app, nav, thread, menu) { menu = false } |
| 157 | 156 | } |
| 158 | 157 | |
| 158 | + /** | |
| 159 | + * The chat header as a phone's bar: the way back to the threads, the title | |
| 160 | + * over its project (or the thread that delegated it), which opens the | |
| 161 | + * thread menu, and the right panel's toggle. | |
| 162 | + */ | |
| 163 | + @Composable | |
| 164 | + fun RowScope.PhoneChatBar(app: AppState, nav: Nav, thread: OpenThread) { | |
| 165 | + val p = palette | |
| 166 | + val context = LocalContext.current | |
| 167 | + val summary = thread.summary | |
| 168 | + val host = thread.host | |
| 169 | + val projectName = thread.project?.name ?: summary.project.trimEnd('/').substringAfterLast('/') | |
| 170 | + val parent = summary.parent?.let { host.threads[it] } | |
| 171 | + val subagentsWorking by remember(thread) { derivedStateOf { workingSubagents(thread.items) } } | |
| 172 | + var menu by remember { mutableStateOf(false) } | |
| 173 | + | |
| 174 | + // A subagent's thread goes back to its parent; any other to the list. | |
| 175 | + BarBack { | |
| 176 | + if (parent != null) nav.openThread(ThreadRef(host.saved.id, parent.id)) else if (!nav.back()) nav.home() | |
| 177 | + } | |
| 178 | + Column( | |
| 179 | + Modifier | |
| 180 | + .weight(1f) | |
| 181 | + .clip(RoundedCornerShape(12.dp)) | |
| 182 | + .clickable { menu = true } | |
| 183 | + .padding(horizontal = 6.dp, vertical = 4.dp), | |
| 184 | + verticalArrangement = Arrangement.spacedBy(2.dp), | |
| 185 | + ) { | |
| 186 | + Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) { | |
| 187 | + Txt( | |
| 188 | + summary.title.ifBlank { "New thread" }, | |
| 189 | + Modifier.weight(1f, fill = false), | |
| 190 | + color = p.ink, | |
| 191 | + size = 16.sp, | |
| 192 | + weight = FontWeight.SemiBold, | |
| 193 | + maxLines = 1, | |
| 194 | + ) | |
| 195 | + LucideIcon(Lucide.ChevronDown, size = 14.dp, tint = p.ink3) | |
| 196 | + } | |
| 197 | + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { | |
| 198 | + if (projectName.isNotEmpty()) Badge(initials(projectName), tintFor(projectName)) | |
| 199 | + if (parent != null) LucideIcon(Lucide.Bot, size = 12.dp, tint = p.ink3) | |
| 200 | + val where = listOfNotNull( | |
| 201 | + parent?.title ?: projectName.ifEmpty { null }, | |
| 202 | + host.name, | |
| 203 | + ).joinToString(" · ") | |
| 204 | + Txt(where, Modifier.weight(1f, fill = false), color = p.ink3, size = 12.5.sp, maxLines = 1) | |
| 205 | + } | |
| 206 | + } | |
| 207 | + thread.detail.pullRequest?.let { pr -> | |
| 208 | + PullRequestBadge(pr) { | |
| 209 | + runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, android.net.Uri.parse(pr.url))) } | |
| 210 | + } | |
| 211 | + } | |
| 212 | + Box { | |
| 213 | + BarAction(Lucide.PanelRightOpen, selected = nav.surface != null) { nav.surfacePicker = true } | |
| 214 | + if (subagentsWorking > 0 && nav.surface != Surface.Subagents) { | |
| 215 | + StatusLed(Led.Live, Modifier.align(Alignment.TopEnd).padding(top = 8.dp, end = 8.dp), pulse = true) | |
| 216 | + } | |
| 217 | + } | |
| 218 | + ThreadMenu(app, nav, thread, menu) { menu = false } | |
| 219 | + } | |
| 220 | + | |
| 159 | 221 | /** A pull request's state and number, as the sidebar marks threads with one. */ |
| 160 | 222 | @Composable |
| 161 | 223 | fun PullRequestBadge(pr: PullRequest, onClick: () -> Unit) { |
| @@ -9,7 +9,9 @@ import androidx.compose.runtime.DisposableEffect | ||
| 9 | 9 | import androidx.compose.ui.Modifier |
| 10 | 10 | import cc.vectrum.code.data.AppState |
| 11 | 11 | import cc.vectrum.code.data.HostState |
| 12 | + import cc.vectrum.code.ui.LocalPhone | |
| 12 | 13 | import cc.vectrum.code.ui.Nav |
| 14 | + import cc.vectrum.code.ui.PhoneScaffold | |
| 13 | 15 | import cc.vectrum.code.ui.composer.Composer |
| 14 | 16 | import cc.vectrum.code.ui.composer.Dock |
| 15 | 17 | |
| @@ -23,12 +25,21 @@ fun ThreadScreen(app: AppState, nav: Nav, host: HostState, id: String) { | ||
| 23 | 25 | DisposableEffect(host, id) { |
| 24 | 26 | onDispose { host.close(id) } |
| 25 | 27 | } |
| 26 | - Column(Modifier.fillMaxSize()) { | |
| 27 | - ChatHeader(app, nav, thread) | |
| 28 | - Box(Modifier.weight(1f).fillMaxWidth()) { | |
| 29 | - Timeline(app, nav, thread, Modifier.fillMaxSize()) | |
| 28 | + val body = @Composable { modifier: Modifier -> | |
| 29 | + Column(modifier) { | |
| 30 | + Box(Modifier.weight(1f).fillMaxWidth()) { | |
| 31 | + Timeline(app, nav, thread, Modifier.fillMaxSize()) | |
| 32 | + } | |
| 33 | + Dock(app, nav, thread) | |
| 34 | + Composer(app, nav, thread) | |
| 35 | + } | |
| 36 | + } | |
| 37 | + if (LocalPhone.current) { | |
| 38 | + PhoneScaffold(bar = { PhoneChatBar(app, nav, thread) }) { body(Modifier.weight(1f).fillMaxWidth()) } | |
| 39 | + } else { | |
| 40 | + Column(Modifier.fillMaxSize()) { | |
| 41 | + ChatHeader(app, nav, thread) | |
| 42 | + body(Modifier.weight(1f).fillMaxWidth()) | |
| 30 | 43 | } |
| 31 | - Dock(app, nav, thread) | |
| 32 | - Composer(app, nav, thread) | |
| 33 | 44 | } |
| 34 | 45 | } |
| @@ -54,7 +54,11 @@ import cc.vectrum.code.ui.MenuItem | ||
| 54 | 54 | import cc.vectrum.code.ui.MenuLabel |
| 55 | 55 | import cc.vectrum.code.ui.MenuRow |
| 56 | 56 | import cc.vectrum.code.ui.MenuSeparator |
| 57 | + import cc.vectrum.code.ui.BarBack | |
| 58 | + import cc.vectrum.code.ui.BarTitle | |
| 59 | + import cc.vectrum.code.ui.LocalPhone | |
| 57 | 60 | import cc.vectrum.code.ui.Nav |
| 61 | + import cc.vectrum.code.ui.PhoneScaffold | |
| 58 | 62 | import cc.vectrum.code.ui.Screen |
| 59 | 63 | import cc.vectrum.code.ui.pair.AddProjectSheet |
| 60 | 64 | import cc.vectrum.code.util.initials |
| @@ -88,9 +92,8 @@ fun NewThreadScreen(app: AppState, nav: Nav, screen: Screen.NewThread) { | ||
| 88 | 92 | val project = host?.projects?.find { it.path == pick.project } |
| 89 | 93 | var addProject by remember { mutableStateOf(false) } |
| 90 | 94 | |
| 91 | - Column(Modifier.fillMaxSize()) { | |
| 92 | - TopRow(nav) | |
| 93 | - BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) { | |
| 95 | + val start = @Composable { modifier: Modifier -> | |
| 96 | + BoxWithConstraints(modifier) { | |
| 94 | 97 | Column( |
| 95 | 98 | Modifier |
| 96 | 99 | .fillMaxWidth() |
| @@ -107,16 +110,17 @@ fun NewThreadScreen(app: AppState, nav: Nav, screen: Screen.NewThread) { | ||
| 107 | 110 | } |
| 108 | 111 | } |
| 109 | 112 | } |
| 110 | - AddProjectSheet(app, host ?: connected.firstOrNull(), addProject) { addProject = false } | |
| 111 | - } | |
| 112 | - | |
| 113 | - /** The sidebar's button, on a phone where the sidebar is a drawer. */ | |
| 114 | - @Composable | |
| 115 | - private fun TopRow(nav: Nav) { | |
| 116 | - if (LocalConfiguration.current.screenWidthDp >= 840) return | |
| 117 | - Row(Modifier.fillMaxWidth().padding(8.dp), verticalAlignment = Alignment.CenterVertically) { | |
| 118 | - Cap(icon = Lucide.PanelLeftOpen, kind = CapKind.Ghost, size = CapSize.Regular) { nav.sidebarOpen = true } | |
| 113 | + if (LocalPhone.current) { | |
| 114 | + PhoneScaffold( | |
| 115 | + bar = { | |
| 116 | + BarBack { if (!nav.back()) nav.home() } | |
| 117 | + BarTitle("New thread") | |
| 118 | + }, | |
| 119 | + ) { start(Modifier.weight(1f).fillMaxWidth()) } | |
| 120 | + } else { | |
| 121 | + start(Modifier.fillMaxSize()) | |
| 119 | 122 | } |
| 123 | + AddProjectSheet(app, host ?: connected.firstOrNull(), addProject) { addProject = false } | |
| 120 | 124 | } |
| 121 | 125 | |
| 122 | 126 | /** No host to start a thread on, or one without projects. */ |
| @@ -57,7 +57,11 @@ import cc.vectrum.code.theme.palette | ||
| 57 | 57 | import cc.vectrum.code.theme.well |
| 58 | 58 | import cc.vectrum.code.ui.Lucide |
| 59 | 59 | import cc.vectrum.code.ui.LucideIcon |
| 60 | + import cc.vectrum.code.ui.BarAction | |
| 61 | + import cc.vectrum.code.ui.BarBack | |
| 62 | + import cc.vectrum.code.ui.LocalPhone | |
| 60 | 63 | import cc.vectrum.code.ui.Nav |
| 64 | + import cc.vectrum.code.ui.PhoneScaffold | |
| 61 | 65 | import cc.vectrum.code.ui.Screen |
| 62 | 66 | |
| 63 | 67 | // The diff view a turn's changes open (src/ui/changes.rs): every file the |
| @@ -104,9 +108,43 @@ fun DiffScreen(app: AppState, nav: Nav, screen: Screen.Diff) { | ||
| 104 | 108 | val anyOpen = files.any { collapsed[it.path] != true } |
| 105 | 109 | val ink = diffInk(p, prefs.string("diff_colors")) |
| 106 | 110 | |
| 111 | + val counts = @Composable { | |
| 112 | + if (files.isNotEmpty()) { | |
| 113 | + Counts( | |
| 114 | + files.sumOf { file -> file.lines.count { it.kind == "added" }.toLong() }, | |
| 115 | + files.sumOf { file -> file.lines.count { it.kind == "removed" }.toLong() }, | |
| 116 | + ink, | |
| 117 | + ) | |
| 118 | + } | |
| 119 | + } | |
| 120 | + val toggleAll = { if (anyOpen) files.forEach { collapsed[it.path] = true } else collapsed.clear() } | |
| 121 | + val body = @Composable { wide: Boolean, split: Boolean -> | |
| 122 | + val current = reply | |
| 123 | + when { | |
| 124 | + current == null -> Message("Reading the diff…", p.ink3) | |
| 125 | + current.error != null -> Message("Couldn't read the diff: ${current.error}", p.red) | |
| 126 | + files.isEmpty() -> Message(if (ignoreWhitespace) "Only whitespace changed." else "Nothing changed.", p.ink3) | |
| 127 | + else -> DiffBody(files, collapsed, split, scrolls, ink, if (wide) 16.dp else 12.dp) | |
| 128 | + } | |
| 129 | + } | |
| 130 | + | |
| 107 | 131 | BoxWithConstraints(Modifier.fillMaxSize()) { |
| 108 | 132 | val wide = maxWidth >= 700.dp |
| 109 | 133 | val split = wide && sideBySide |
| 134 | + if (LocalPhone.current) { | |
| 135 | + PhoneScaffold( | |
| 136 | + bar = { | |
| 137 | + BarBack { nav.back() } | |
| 138 | + Column(Modifier.weight(1f).padding(start = 6.dp)) { | |
| 139 | + Txt(title, color = p.ink, size = 16.sp, weight = FontWeight.SemiBold, maxLines = 1) | |
| 140 | + counts() | |
| 141 | + } | |
| 142 | + if (files.isNotEmpty()) BarAction(if (anyOpen) Lucide.ChevronsDownUp else Lucide.ChevronsUpDown, onClick = toggleAll) | |
| 143 | + BarAction(Lucide.Space, selected = ignoreWhitespace) { ignoreWhitespace = !ignoreWhitespace } | |
| 144 | + }, | |
| 145 | + ) { body(wide, split) } | |
| 146 | + return@BoxWithConstraints | |
| 147 | + } | |
| 110 | 148 | Column(Modifier.fillMaxSize()) { |
| 111 | 149 | Row( |
| 112 | 150 | Modifier.fillMaxWidth().height(52.dp).padding(start = 8.dp, end = 8.dp), |
| @@ -117,20 +155,12 @@ fun DiffScreen(app: AppState, nav: Nav, screen: Screen.Diff) { | ||
| 117 | 155 | LucideIcon(Lucide.FileDiff, size = 15.dp, tint = p.ink2) |
| 118 | 156 | Column(Modifier.weight(1f)) { |
| 119 | 157 | Txt(title, color = p.ink, size = 13.5.sp, weight = FontWeight.SemiBold, maxLines = 1) |
| 120 | - if (files.isNotEmpty()) { | |
| 121 | - Counts( | |
| 122 | - files.sumOf { file -> file.lines.count { it.kind == "added" }.toLong() }, | |
| 123 | - files.sumOf { file -> file.lines.count { it.kind == "removed" }.toLong() }, | |
| 124 | - ink, | |
| 125 | - ) | |
| 126 | - } | |
| 158 | + counts() | |
| 127 | 159 | } |
| 128 | 160 | if (files.isNotEmpty()) { |
| 129 | 161 | val icon = if (anyOpen) Lucide.ChevronsDownUp else Lucide.ChevronsUpDown |
| 130 | 162 | val label = if (anyOpen) "Collapse all" else "Expand all" |
| 131 | - Cap(label = label.takeIf { wide }, icon = icon, kind = CapKind.Ghost, size = CapSize.Compact) { | |
| 132 | - if (anyOpen) files.forEach { collapsed[it.path] = true } else collapsed.clear() | |
| 133 | - } | |
| 163 | + Cap(label = label.takeIf { wide }, icon = icon, kind = CapKind.Ghost, size = CapSize.Compact, onClick = toggleAll) | |
| 134 | 164 | } |
| 135 | 165 | Cap(icon = Lucide.Space, kind = CapKind.Ghost, size = CapSize.Compact, selected = ignoreWhitespace) { |
| 136 | 166 | ignoreWhitespace = !ignoreWhitespace |
| @@ -138,13 +168,7 @@ fun DiffScreen(app: AppState, nav: Nav, screen: Screen.Diff) { | ||
| 138 | 168 | if (wide) Segmented(listOf(false to "Stacked", true to "Side by side"), sideBySide) { sideBySide = it } |
| 139 | 169 | } |
| 140 | 170 | Rule() |
| 141 | - val current = reply | |
| 142 | - when { | |
| 143 | - current == null -> Message("Reading the diff…", p.ink3) | |
| 144 | - current.error != null -> Message("Couldn't read the diff: ${current.error}", p.red) | |
| 145 | - files.isEmpty() -> Message(if (ignoreWhitespace) "Only whitespace changed." else "Nothing changed.", p.ink3) | |
| 146 | - else -> DiffBody(files, collapsed, split, scrolls, ink, if (wide) 16.dp else 12.dp) | |
| 147 | - } | |
| 171 | + body(wide, split) | |
| 148 | 172 | } |
| 149 | 173 | } |
| 150 | 174 | } |
| @@ -26,7 +26,6 @@ import androidx.compose.runtime.setValue | ||
| 26 | 26 | import androidx.compose.ui.Alignment |
| 27 | 27 | import androidx.compose.ui.Modifier |
| 28 | 28 | import androidx.compose.ui.graphics.ColorFilter |
| 29 | - import androidx.compose.ui.platform.LocalConfiguration | |
| 30 | 29 | import androidx.compose.ui.platform.LocalContext |
| 31 | 30 | import androidx.compose.ui.res.painterResource |
| 32 | 31 | import androidx.compose.ui.text.font.FontWeight |
| @@ -53,7 +52,11 @@ import cc.vectrum.code.theme.palette | ||
| 53 | 52 | import cc.vectrum.code.theme.raised |
| 54 | 53 | import cc.vectrum.code.ui.Field |
| 55 | 54 | import cc.vectrum.code.ui.Lucide |
| 55 | + import cc.vectrum.code.ui.BarBack | |
| 56 | + import cc.vectrum.code.ui.BarTitle | |
| 57 | + import cc.vectrum.code.ui.LocalPhone | |
| 56 | 58 | import cc.vectrum.code.ui.Nav |
| 59 | + import cc.vectrum.code.ui.PhoneScaffold | |
| 57 | 60 | import cc.vectrum.code.ui.Screen |
| 58 | 61 | import kotlinx.coroutines.delay |
| 59 | 62 | |
| @@ -108,24 +111,28 @@ fun PairScreen(app: AppState, nav: Nav) { | ||
| 108 | 111 | } |
| 109 | 112 | } |
| 110 | 113 | |
| 111 | - Column(Modifier.fillMaxSize()) { | |
| 112 | - TopBar(nav, onPairScreen) | |
| 114 | + val phone = LocalPhone.current | |
| 115 | + val big = if (phone) CapSize.Large else CapSize.Regular | |
| 116 | + val form = @Composable { modifier: Modifier -> | |
| 113 | 117 | Box( |
| 114 | - Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).imePadding(), | |
| 118 | + modifier.verticalScroll(rememberScrollState()).imePadding(), | |
| 115 | 119 | contentAlignment = Alignment.TopCenter, |
| 116 | 120 | ) { |
| 117 | 121 | Column( |
| 118 | - Modifier.widthIn(max = 440.dp).fillMaxWidth().padding(start = 20.dp, end = 20.dp, top = 16.dp, bottom = 40.dp), | |
| 122 | + Modifier.widthIn(max = 440.dp).fillMaxWidth().padding(start = 20.dp, end = 20.dp, top = if (phone) 28.dp else 16.dp, bottom = 40.dp), | |
| 119 | 123 | horizontalAlignment = Alignment.CenterHorizontally, |
| 120 | 124 | verticalArrangement = Arrangement.spacedBy(14.dp), |
| 121 | 125 | ) { |
| 122 | - Image( | |
| 123 | - painterResource(R.drawable.vectrum_logo), | |
| 124 | - contentDescription = null, | |
| 125 | - modifier = Modifier.size(32.dp), | |
| 126 | - colorFilter = ColorFilter.tint(p.ink), | |
| 127 | - ) | |
| 128 | - Engraved("Pair with a computer") | |
| 126 | + // On a phone the bar says it. | |
| 127 | + if (!phone) { | |
| 128 | + Image( | |
| 129 | + painterResource(R.drawable.vectrum_logo), | |
| 130 | + contentDescription = null, | |
| 131 | + modifier = Modifier.size(32.dp), | |
| 132 | + colorFilter = ColorFilter.tint(p.ink), | |
| 133 | + ) | |
| 134 | + Engraved("Pair with a computer") | |
| 135 | + } | |
| 129 | 136 | Txt( |
| 130 | 137 | "Work on your computer's threads from here.", |
| 131 | 138 | color = p.ink, |
| @@ -168,7 +175,7 @@ fun PairScreen(app: AppState, nav: Nav) { | ||
| 168 | 175 | Txt(error!!, Modifier.fillMaxWidth().padding(horizontal = 4.dp), color = p.red, size = 12.5.sp, lineHeight = 18.sp) |
| 169 | 176 | } |
| 170 | 177 | Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { |
| 171 | - Cap(Modifier.weight(1f), label = "Scan QR code", icon = Lucide.ScanLine) { | |
| 178 | + Cap(Modifier.weight(1f), label = "Scan QR code", icon = Lucide.ScanLine, size = big) { | |
| 172 | 179 | scanQrCode( |
| 173 | 180 | context, |
| 174 | 181 | onCode = { text -> |
| @@ -184,6 +191,7 @@ fun PairScreen(app: AppState, nav: Nav) { | ||
| 184 | 191 | label = "Connect", |
| 185 | 192 | icon = Lucide.Plug, |
| 186 | 193 | kind = CapKind.Primary, |
| 194 | + size = big, | |
| 187 | 195 | enabled = addresses.isNotEmpty() && (!needsKey || key.text.isNotBlank()), |
| 188 | 196 | ) { connect() } |
| 189 | 197 | } |
| @@ -199,20 +207,29 @@ fun PairScreen(app: AppState, nav: Nav) { | ||
| 199 | 207 | } |
| 200 | 208 | } |
| 201 | 209 | } |
| 210 | + if (phone) { | |
| 211 | + PhoneScaffold( | |
| 212 | + bar = { | |
| 213 | + BarBack { if (!nav.back()) nav.home() } | |
| 214 | + BarTitle("Pair a computer") | |
| 215 | + }, | |
| 216 | + ) { form(Modifier.weight(1f).fillMaxWidth()) } | |
| 217 | + } else { | |
| 218 | + Column(Modifier.fillMaxSize()) { | |
| 219 | + TopBar(nav, onPairScreen) | |
| 220 | + form(Modifier.weight(1f).fillMaxWidth()) | |
| 221 | + } | |
| 222 | + } | |
| 202 | 223 | } |
| 203 | 224 | |
| 204 | - /** Back to where the Pair screen was opened from, or the sidebar on Home. */ | |
| 225 | + /** Back to where the Pair screen was opened from, on a tablet; Home has nothing to go back to. */ | |
| 205 | 226 | @Composable |
| 206 | 227 | private fun TopBar(nav: Nav, onPairScreen: Boolean) { |
| 207 | - val drawer = LocalConfiguration.current.screenWidthDp < 840 | |
| 208 | 228 | Row( |
| 209 | 229 | Modifier.fillMaxWidth().height(52.dp).padding(horizontal = 8.dp), |
| 210 | 230 | verticalAlignment = Alignment.CenterVertically, |
| 211 | 231 | ) { |
| 212 | - when { | |
| 213 | - onPairScreen -> Cap(icon = Lucide.ArrowLeft, kind = CapKind.Ghost, size = CapSize.Compact) { if (!nav.back()) nav.home() } | |
| 214 | - drawer -> Cap(icon = Lucide.PanelLeftOpen, kind = CapKind.Ghost, size = CapSize.Compact) { nav.sidebarOpen = true } | |
| 215 | - } | |
| 232 | + if (onPairScreen) Cap(icon = Lucide.ArrowLeft, kind = CapKind.Ghost, size = CapSize.Compact) { if (!nav.back()) nav.home() } | |
| 216 | 233 | } |
| 217 | 234 | } |
| 218 | 235 | |
| @@ -9,7 +9,12 @@ import androidx.compose.foundation.layout.Arrangement | ||
| 9 | 9 | import androidx.compose.foundation.layout.Box |
| 10 | 10 | import androidx.compose.foundation.layout.Column |
| 11 | 11 | import androidx.compose.foundation.layout.Row |
| 12 | + import androidx.compose.foundation.layout.WindowInsets | |
| 13 | + import androidx.compose.foundation.layout.WindowInsetsSides | |
| 12 | 14 | import androidx.compose.foundation.layout.fillMaxSize |
| 15 | + import androidx.compose.foundation.layout.only | |
| 16 | + import androidx.compose.foundation.layout.safeDrawing | |
| 17 | + import androidx.compose.foundation.layout.windowInsetsPadding | |
| 13 | 18 | import androidx.compose.foundation.layout.fillMaxWidth |
| 14 | 19 | import androidx.compose.foundation.layout.height |
| 15 | 20 | import androidx.compose.foundation.layout.padding |
| @@ -19,6 +24,7 @@ import androidx.compose.runtime.Composable | ||
| 19 | 24 | import androidx.compose.runtime.remember |
| 20 | 25 | import androidx.compose.ui.Alignment |
| 21 | 26 | import androidx.compose.ui.Modifier |
| 27 | + import androidx.compose.ui.graphics.Shape | |
| 22 | 28 | import androidx.compose.ui.draw.clip |
| 23 | 29 | import androidx.compose.ui.text.font.FontWeight |
| 24 | 30 | import androidx.compose.ui.text.style.TextAlign |
| @@ -58,11 +64,10 @@ private val SURFACES = listOf( | ||
| 58 | 64 | |
| 59 | 65 | /** The right panel over a thread: "Open a surface", or the surface picked. */ |
| 60 | 66 | @Composable |
| 61 | - fun RightPanel(app: AppState, nav: Nav, ref: ThreadRef) { | |
| 67 | + fun RightPanel(app: AppState, nav: Nav, ref: ThreadRef, shape: Shape = RoundedCornerShape(SheetRadius)) { | |
| 62 | 68 | val p = palette |
| 63 | 69 | val shown = remember { MutableTransitionState(false) } |
| 64 | 70 | shown.targetState = true |
| 65 | - val shape = RoundedCornerShape(SheetRadius) | |
| 66 | 71 | AnimatedVisibility( |
| 67 | 72 | visibleState = shown, |
| 68 | 73 | enter = slideInHorizontally { it / 3 } + fadeIn(), |
| @@ -73,7 +78,9 @@ fun RightPanel(app: AppState, nav: Nav, ref: ThreadRef) { | ||
| 73 | 78 | .plate(p, shape) |
| 74 | 79 | .clip(shape) |
| 75 | 80 | // The thread underneath doesn't take touches meant for the panel. |
| 76 | - .clickable(interactionSource = null, indication = null) {}, | |
| 81 | + .clickable(interactionSource = null, indication = null) {} | |
| 82 | + // On a phone the panel runs under the navigation bar, as the sheet does. | |
| 83 | + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)), | |
| 77 | 84 | ) { |
| 78 | 85 | val surface = nav.surface |
| 79 | 86 | when { |
| @@ -38,7 +38,6 @@ import androidx.compose.ui.Modifier | ||
| 38 | 38 | import androidx.compose.ui.draw.clip |
| 39 | 39 | import androidx.compose.ui.graphics.ColorFilter |
| 40 | 40 | import androidx.compose.ui.graphics.SolidColor |
| 41 | - import androidx.compose.ui.platform.LocalConfiguration | |
| 42 | 41 | import androidx.compose.ui.platform.LocalContext |
| 43 | 42 | import androidx.compose.ui.res.painterResource |
| 44 | 43 | import androidx.compose.ui.text.TextStyle |
| @@ -49,6 +48,7 @@ import androidx.compose.ui.unit.dp | ||
| 49 | 48 | import androidx.compose.ui.unit.sp |
| 50 | 49 | import cc.vectrum.code.R |
| 51 | 50 | import cc.vectrum.code.data.AppState |
| 51 | + import cc.vectrum.code.data.DevicePrefs | |
| 52 | 52 | import cc.vectrum.code.theme.Cap |
| 53 | 53 | import cc.vectrum.code.theme.CapKind |
| 54 | 54 | import cc.vectrum.code.theme.CapSize |
| @@ -57,12 +57,17 @@ import cc.vectrum.code.theme.Mono | ||
| 57 | 57 | import cc.vectrum.code.theme.Rule |
| 58 | 58 | import cc.vectrum.code.theme.Sans |
| 59 | 59 | import cc.vectrum.code.theme.Tag |
| 60 | + import cc.vectrum.code.theme.Themes | |
| 60 | 61 | import cc.vectrum.code.theme.Txt |
| 61 | 62 | import cc.vectrum.code.theme.VRule |
| 62 | 63 | import cc.vectrum.code.theme.palette |
| 63 | 64 | import cc.vectrum.code.theme.raised |
| 64 | 65 | import cc.vectrum.code.theme.well |
| 66 | + import cc.vectrum.code.ui.BarBack | |
| 67 | + import cc.vectrum.code.ui.BarTitle | |
| 65 | 68 | import cc.vectrum.code.ui.ConfirmDialog |
| 69 | + import cc.vectrum.code.ui.LocalPhone | |
| 70 | + import cc.vectrum.code.ui.PhoneScaffold | |
| 66 | 71 | import cc.vectrum.code.ui.Lucide |
| 67 | 72 | import cc.vectrum.code.ui.LucideIcon |
| 68 | 73 | import cc.vectrum.code.ui.Nav |
| @@ -74,7 +79,8 @@ import kotlinx.coroutines.delay | ||
| 74 | 79 | // breadcrumb. On a phone the page list comes first and each page fills the |
| 75 | 80 | // sheet; on a tablet they sit side by side, as on the desktop. Every change |
| 76 | 81 | // is saved at once: this phone's preferences here, environment settings on |
| 77 | - // the hosts. | |
| 82 | + // the hosts. On a phone the list is a hub of grouped rows, as phone apps | |
| 83 | + // lay out their settings, and each page has the phone's bar. | |
| 78 | 84 | |
| 79 | 85 | /** The widest a settings page grows (T3 Code's "readable" width). */ |
| 80 | 86 | private val ReadableWidth = 896.dp |
| @@ -101,8 +107,9 @@ fun SettingsScreen(app: AppState, nav: Nav, page: String) { | ||
| 101 | 107 | } |
| 102 | 108 | } |
| 103 | 109 | |
| 110 | + val phone = LocalPhone.current | |
| 104 | 111 | BoxWithConstraints(Modifier.fillMaxSize()) { |
| 105 | - val wide = maxWidth >= 720.dp | |
| 112 | + val wide = !phone && maxWidth >= 720.dp | |
| 106 | 113 | val open = { next: SettingsPage? -> |
| 107 | 114 | target = null |
| 108 | 115 | nav.replace(Screen.Settings(next?.key ?: "")) |
| @@ -129,6 +136,8 @@ fun SettingsScreen(app: AppState, nav: Nav, page: String) { | ||
| 129 | 136 | Spacer(Modifier.width(1.dp).fillMaxHeight().background(palette.rule)) |
| 130 | 137 | PageView(app, nav, current ?: SettingsPage.General, scope, target, back = null, open = open, modifier = Modifier.weight(1f)) |
| 131 | 138 | } |
| 139 | + } else if (current == null && phone) { | |
| 140 | + Hub(app, nav, query, { query = it }, open, jump) | |
| 132 | 141 | } else if (current == null) { |
| 133 | 142 | list(Modifier.fillMaxSize()) |
| 134 | 143 | } else { |
| @@ -151,16 +160,14 @@ private fun PageList( | ||
| 151 | 160 | ) { |
| 152 | 161 | val p = palette |
| 153 | 162 | val context = LocalContext.current |
| 154 | - val drawer = LocalConfiguration.current.screenWidthDp < 840 | |
| 155 | 163 | val searching = query.text.isNotBlank() |
| 156 | 164 | val results = searchSettings(query.text) |
| 157 | 165 | Column(modifier) { |
| 158 | 166 | Row( |
| 159 | - Modifier.fillMaxWidth().height(52.dp).padding(start = if (drawer) 8.dp else 16.dp, end = 16.dp), | |
| 167 | + Modifier.fillMaxWidth().height(52.dp).padding(start = 16.dp, end = 16.dp), | |
| 160 | 168 | horizontalArrangement = Arrangement.spacedBy(8.dp), |
| 161 | 169 | verticalAlignment = Alignment.CenterVertically, |
| 162 | 170 | ) { |
| 163 | - if (drawer) Cap(icon = Lucide.PanelLeftOpen, kind = CapKind.Ghost, size = CapSize.Compact) { nav.sidebarOpen = true } | |
| 164 | 171 | Image( |
| 165 | 172 | painterResource(R.drawable.vectrum_logo), |
| 166 | 173 | contentDescription = null, |
| @@ -204,10 +211,14 @@ private fun PageList( | ||
| 204 | 211 | } |
| 205 | 212 | |
| 206 | 213 | @Composable |
| 207 | - private fun SearchField(query: TextFieldValue, onQuery: (TextFieldValue) -> Unit) { | |
| 214 | + private fun SearchField(query: TextFieldValue, onQuery: (TextFieldValue) -> Unit, phone: Boolean = false) { | |
| 208 | 215 | val p = palette |
| 209 | 216 | Row( |
| 210 | - Modifier.fillMaxWidth().height(36.dp).well(p, RoundedCornerShape(12.dp)).padding(start = 10.dp, end = 4.dp), | |
| 217 | + Modifier | |
| 218 | + .fillMaxWidth() | |
| 219 | + .height(if (phone) 44.dp else 36.dp) | |
| 220 | + .well(p, RoundedCornerShape(if (phone) 16.dp else 12.dp)) | |
| 221 | + .padding(start = if (phone) 14.dp else 10.dp, end = 4.dp), | |
| 211 | 222 | horizontalArrangement = Arrangement.spacedBy(8.dp), |
| 212 | 223 | verticalAlignment = Alignment.CenterVertically, |
| 213 | 224 | ) { |
| @@ -287,7 +298,19 @@ private fun PageView( | ||
| 287 | 298 | ) { |
| 288 | 299 | val p = palette |
| 289 | 300 | val prefs = app.prefs |
| 290 | - var restoring by remember { mutableStateOf(false) } | |
| 301 | + if (LocalPhone.current && back != null) { | |
| 302 | + PhoneScaffold( | |
| 303 | + bar = { | |
| 304 | + BarBack(back) | |
| 305 | + BarTitle(page.label) | |
| 306 | + if (page == SettingsPage.General) RestoreDefaults(prefs, phone = true) | |
| 307 | + }, | |
| 308 | + modifier = modifier, | |
| 309 | + ) { | |
| 310 | + PageBody(app, nav, page, scope, target, narrow = true, Modifier.weight(1f)) | |
| 311 | + } | |
| 312 | + return | |
| 313 | + } | |
| 291 | 314 | BoxWithConstraints(modifier) { |
| 292 | 315 | val narrow = maxWidth < 480.dp |
| 293 | 316 | Column(Modifier.fillMaxSize()) { |
| @@ -309,57 +332,216 @@ private fun PageView( | ||
| 309 | 332 | ) |
| 310 | 333 | Txt("/", color = p.ink3, size = 14.sp) |
| 311 | 334 | Txt(page.label, Modifier.weight(1f), color = p.ink, size = 14.sp, weight = FontWeight.SemiBold, maxLines = 1) |
| 312 | - if (page == SettingsPage.General) { | |
| 313 | - val changed = differingDefaults(prefs) | |
| 314 | - Cap( | |
| 315 | - label = if (narrow) null else "Restore device defaults", | |
| 316 | - icon = Lucide.RotateCcw, | |
| 317 | - kind = CapKind.Ghost, | |
| 318 | - size = CapSize.Compact, | |
| 319 | - enabled = changed.isNotEmpty(), | |
| 320 | - ) { restoring = true } | |
| 321 | - if (restoring) { | |
| 322 | - ConfirmDialog( | |
| 323 | - title = "Restore default settings?", | |
| 324 | - message = "This will reset: ${changed.joinToString(", ")}.", | |
| 325 | - confirm = "Restore defaults", | |
| 326 | - danger = true, | |
| 327 | - onDismiss = { restoring = false }, | |
| 328 | - onConfirm = { restoreDefaults(prefs) }, | |
| 329 | - ) | |
| 330 | - } | |
| 331 | - } | |
| 335 | + if (page == SettingsPage.General) RestoreDefaults(prefs, phone = false, narrow = narrow) | |
| 332 | 336 | } |
| 333 | 337 | Rule() |
| 334 | - val scroll = remember(page) { ScrollState(0) } | |
| 335 | - Box( | |
| 336 | - Modifier.weight(1f).fillMaxWidth().verticalScroll(scroll), | |
| 337 | - contentAlignment = Alignment.TopCenter, | |
| 338 | - ) { | |
| 339 | - val width = if (page == SettingsPage.Providers || page == SettingsPage.Connections) WideWidth else ReadableWidth | |
| 340 | - Column( | |
| 341 | - Modifier | |
| 342 | - .widthIn(max = width) | |
| 343 | - .fillMaxWidth() | |
| 344 | - .padding(start = if (narrow) 12.dp else 24.dp, end = if (narrow) 12.dp else 24.dp, top = 24.dp, bottom = 48.dp), | |
| 345 | - verticalArrangement = Arrangement.spacedBy(32.dp), | |
| 346 | - ) { | |
| 347 | - CompositionLocalProvider(LocalTarget provides target) { | |
| 348 | - val shown = shown(app, scope) | |
| 349 | - if (page.scoped) ScopeSentence(app, scope) | |
| 350 | - when (page) { | |
| 351 | - SettingsPage.General -> GeneralPage(app, shown) | |
| 352 | - SettingsPage.Appearance -> AppearancePage(prefs) | |
| 353 | - SettingsPage.Providers -> ProvidersPage(app) | |
| 354 | - SettingsPage.SourceControl -> SourceControlPage(shown) | |
| 355 | - SettingsPage.Storage -> StoragePage(shown) | |
| 356 | - SettingsPage.Connections -> ConnectionsPage(app, nav) | |
| 357 | - SettingsPage.Archive -> ArchivePage(app) | |
| 358 | - SettingsPage.About -> AboutPage(app) | |
| 359 | - } | |
| 360 | - } | |
| 338 | + PageBody(app, nav, page, scope, target, narrow, Modifier.weight(1f)) | |
| 339 | + } | |
| 340 | + } | |
| 341 | + } | |
| 342 | + | |
| 343 | + /** General's "Restore device defaults", and the confirmation it asks for. */ | |
| 344 | + @Composable | |
| 345 | + private fun RestoreDefaults(prefs: DevicePrefs, phone: Boolean, narrow: Boolean = true) { | |
| 346 | + var restoring by remember { mutableStateOf(false) } | |
| 347 | + val changed = differingDefaults(prefs) | |
| 348 | + Cap( | |
| 349 | + label = if (narrow) null else "Restore device defaults", | |
| 350 | + icon = Lucide.RotateCcw, | |
| 351 | + kind = CapKind.Ghost, | |
| 352 | + size = if (phone) CapSize.Large else CapSize.Compact, | |
| 353 | + iconColor = if (phone && changed.isNotEmpty()) palette.ink else null, | |
| 354 | + enabled = changed.isNotEmpty(), | |
| 355 | + ) { restoring = true } | |
| 356 | + if (restoring) { | |
| 357 | + ConfirmDialog( | |
| 358 | + title = "Restore default settings?", | |
| 359 | + message = "This will reset: ${changed.joinToString(", ")}.", | |
| 360 | + confirm = "Restore defaults", | |
| 361 | + danger = true, | |
| 362 | + onDismiss = { restoring = false }, | |
| 363 | + onConfirm = { restoreDefaults(prefs) }, | |
| 364 | + ) | |
| 365 | + } | |
| 366 | + } | |
| 367 | + | |
| 368 | + /** A page's settings, scrolling. */ | |
| 369 | + @Composable | |
| 370 | + private fun PageBody( | |
| 371 | + app: AppState, | |
| 372 | + nav: Nav, | |
| 373 | + page: SettingsPage, | |
| 374 | + scope: SettingsScope, | |
| 375 | + target: Target?, | |
| 376 | + narrow: Boolean, | |
| 377 | + modifier: Modifier, | |
| 378 | + ) { | |
| 379 | + val scroll = remember(page) { ScrollState(0) } | |
| 380 | + Box( | |
| 381 | + modifier.fillMaxWidth().verticalScroll(scroll), | |
| 382 | + contentAlignment = Alignment.TopCenter, | |
| 383 | + ) { | |
| 384 | + val width = if (page == SettingsPage.Providers || page == SettingsPage.Connections) WideWidth else ReadableWidth | |
| 385 | + Column( | |
| 386 | + Modifier | |
| 387 | + .widthIn(max = width) | |
| 388 | + .fillMaxWidth() | |
| 389 | + .padding(start = if (narrow) 12.dp else 24.dp, end = if (narrow) 12.dp else 24.dp, top = 24.dp, bottom = 48.dp), | |
| 390 | + verticalArrangement = Arrangement.spacedBy(32.dp), | |
| 391 | + ) { | |
| 392 | + CompositionLocalProvider(LocalTarget provides target) { | |
| 393 | + val shown = shown(app, scope) | |
| 394 | + if (page.scoped) ScopeSentence(app, scope) | |
| 395 | + when (page) { | |
| 396 | + SettingsPage.General -> GeneralPage(app, shown) | |
| 397 | + SettingsPage.Appearance -> AppearancePage(app.prefs) | |
| 398 | + SettingsPage.Providers -> ProvidersPage(app) | |
| 399 | + SettingsPage.SourceControl -> SourceControlPage(shown) | |
| 400 | + SettingsPage.Storage -> StoragePage(shown) | |
| 401 | + SettingsPage.Connections -> ConnectionsPage(app, nav) | |
| 402 | + SettingsPage.Archive -> ArchivePage(app) | |
| 403 | + SettingsPage.About -> AboutPage(app) | |
| 404 | + } | |
| 405 | + } | |
| 406 | + } | |
| 407 | + } | |
| 408 | + } | |
| 409 | + | |
| 410 | + // ---- the phone's hub ------------------------------------------------------------- | |
| 411 | + | |
| 412 | + /** The hub's groups, in order: a title and its pages. */ | |
| 413 | + private val HubGroups = listOf( | |
| 414 | + "Connections" to listOf(SettingsPage.Connections), | |
| 415 | + "Interface" to listOf(SettingsPage.Appearance), | |
| 416 | + "Projects & threads" to listOf(SettingsPage.General, SettingsPage.SourceControl, SettingsPage.Storage, SettingsPage.Archive), | |
| 417 | + "Agents" to listOf(SettingsPage.Providers), | |
| 418 | + "App" to listOf(SettingsPage.About), | |
| 419 | + ) | |
| 420 | + | |
| 421 | + /** What a page's row says under its name, when there's something to say. */ | |
| 422 | + private fun hubDetail(app: AppState, page: SettingsPage, dark: Boolean): String? = when (page) { | |
| 423 | + SettingsPage.Connections -> when (app.hosts.size) { | |
| 424 | + 0 -> "Pair a computer" | |
| 425 | + 1 -> app.hosts.first().name | |
| 426 | + else -> app.hosts.joinToString(", ") { it.name } | |
| 427 | + } | |
| 428 | + SettingsPage.Appearance -> { | |
| 429 | + val prefs = app.prefs | |
| 430 | + val theme = Themes.theme(if (dark) prefs.darkTheme else prefs.lightTheme)?.name | |
| 431 | + val mode = when (prefs.themeMode) { | |
| 432 | + "light" -> "Light" | |
| 433 | + "dark" -> "Dark" | |
| 434 | + else -> "System" | |
| 435 | + } | |
| 436 | + listOfNotNull(theme, mode).joinToString(" · ") | |
| 437 | + } | |
| 438 | + SettingsPage.General -> "Defaults for new threads, behavior" | |
| 439 | + SettingsPage.SourceControl -> "Branch names, commit and PR text" | |
| 440 | + SettingsPage.Storage -> "Worktrees and their cleanup" | |
| 441 | + SettingsPage.Archive -> "Settled threads" | |
| 442 | + SettingsPage.Providers -> "Agents and their models" | |
| 443 | + SettingsPage.About -> null | |
| 444 | + } | |
| 445 | + | |
| 446 | + /** The phone's settings: search, then the pages as grouped rows on raised cards. */ | |
| 447 | + @Composable | |
| 448 | + private fun Hub( | |
| 449 | + app: AppState, | |
| 450 | + nav: Nav, | |
| 451 | + query: TextFieldValue, | |
| 452 | + onQuery: (TextFieldValue) -> Unit, | |
| 453 | + open: (SettingsPage?) -> Unit, | |
| 454 | + jump: (SearchItem) -> Unit, | |
| 455 | + ) { | |
| 456 | + val p = palette | |
| 457 | + val context = LocalContext.current | |
| 458 | + val searching = query.text.isNotBlank() | |
| 459 | + val results = searchSettings(query.text) | |
| 460 | + PhoneScaffold( | |
| 461 | + bar = { | |
| 462 | + BarBack { if (!nav.back()) nav.home() } | |
| 463 | + BarTitle("Settings") | |
| 464 | + }, | |
| 465 | + ) { | |
| 466 | + Column( | |
| 467 | + Modifier.weight(1f).verticalScroll(rememberScrollState()).padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 32.dp), | |
| 468 | + ) { | |
| 469 | + SearchField(query, onQuery, phone = true) | |
| 470 | + when { | |
| 471 | + searching && results.isEmpty() -> Box(Modifier.fillMaxWidth().padding(vertical = 32.dp), contentAlignment = Alignment.Center) { | |
| 472 | + Txt("No settings found", color = p.ink3, size = 14.sp) | |
| 473 | + } | |
| 474 | + searching -> { | |
| 475 | + Spacer(Modifier.height(16.dp)) | |
| 476 | + HubCard(results.map { item -> { HubRow(item.page.icon, item.title, item.page.label) { jump(item) } } }) | |
| 477 | + } | |
| 478 | + else -> for ((title, pages) in HubGroups) { | |
| 479 | + GroupLabel(title) | |
| 480 | + HubCard( | |
| 481 | + pages.map { page -> | |
| 482 | + { | |
| 483 | + val trailing = when (page) { | |
| 484 | + SettingsPage.Connections -> app.hosts.size.toString() | |
| 485 | + SettingsPage.Archive -> settledCount(app).takeIf { it > 0 }?.toString() | |
| 486 | + SettingsPage.About -> "v${appVersion(context)}" | |
| 487 | + else -> null | |
| 488 | + } | |
| 489 | + HubRow(page.icon, page.label, hubDetail(app, page, p.dark), trailing) { open(page) } | |
| 490 | + } | |
| 491 | + }, | |
| 492 | + ) | |
| 361 | 493 | } |
| 362 | 494 | } |
| 363 | 495 | } |
| 364 | 496 | } |
| 365 | 497 | } |
| 498 | + | |
| 499 | + /** A group's title over its card, in the engraved voice. */ | |
| 500 | + @Composable | |
| 501 | + private fun GroupLabel(title: String) { | |
| 502 | + Txt( | |
| 503 | + title.uppercase(), | |
| 504 | + Modifier.padding(start = 16.dp, top = 28.dp, bottom = 10.dp), | |
| 505 | + color = palette.engrave, | |
| 506 | + size = 11.sp, | |
| 507 | + family = Mono, | |
| 508 | + weight = FontWeight.Medium, | |
| 509 | + maxLines = 1, | |
| 510 | + ) | |
| 511 | + } | |
| 512 | + | |
| 513 | + /** Rows on a raised card, with hairlines between them under the text. */ | |
| 514 | + @Composable | |
| 515 | + private fun HubCard(rows: List<@Composable () -> Unit>) { | |
| 516 | + val p = palette | |
| 517 | + val shape = RoundedCornerShape(24.dp) | |
| 518 | + Column(Modifier.fillMaxWidth().raised(p, shape).clip(shape)) { | |
| 519 | + rows.forEachIndexed { ix, row -> | |
| 520 | + row() | |
| 521 | + if (ix + 1 < rows.size) Box(Modifier.padding(start = 54.dp, end = 16.dp)) { Rule() } | |
| 522 | + } | |
| 523 | + } | |
| 524 | + } | |
| 525 | + | |
| 526 | + /** A page on the hub: its icon, name and what's set, and the way in. */ | |
| 527 | + @Composable | |
| 528 | + private fun HubRow(icon: Lucide, title: String, detail: String?, trailing: String? = null, onClick: () -> Unit) { | |
| 529 | + val p = palette | |
| 530 | + Row( | |
| 531 | + Modifier | |
| 532 | + .fillMaxWidth() | |
| 533 | + .defaultMinSize(minHeight = 60.dp) | |
| 534 | + .clickable(onClick = onClick) | |
| 535 | + .padding(start = 18.dp, end = 14.dp, top = 10.dp, bottom = 10.dp), | |
| 536 | + horizontalArrangement = Arrangement.spacedBy(14.dp), | |
| 537 | + verticalAlignment = Alignment.CenterVertically, | |
| 538 | + ) { | |
| 539 | + LucideIcon(icon, size = 20.dp, tint = p.ink2) | |
| 540 | + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { | |
| 541 | + Txt(title, color = p.ink, size = 16.sp, weight = FontWeight.Medium, maxLines = 1) | |
| 542 | + if (detail != null) Txt(detail, color = p.ink3, size = 13.sp, maxLines = 1) | |
| 543 | + } | |
| 544 | + if (trailing != null) Txt(trailing, color = p.ink3, size = 13.sp, family = Mono, maxLines = 1) | |
| 545 | + LucideIcon(Lucide.ChevronRight, size = 16.dp, tint = p.ink3.copy(alpha = 0.7f)) | |
| 546 | + } | |
| 547 | + } | |