From cf65072af4ad8d71c83781172c9a860b5796b9a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:05:07 +0000 Subject: [PATCH] Add a column layout for tablets and unfolded foldables At 600dp and wider, opening a poet turns the home grid into a narrow column of poets on the right, with a column for each level of the open book (books, chapters, poems) beside it and the page in the rest of the screen. Columns narrow as more open and while a poem is being read, and can be hidden for a reader view, with a floating button to bring them back. Below 840dp only the newest list column is shown. The routes are the same as on a phone, so folding or unfolding keeps your place. On large screens the dictionary opens in a panel to the left of the poem rather than as a bottom sheet, and the looked-up word stays highlighted. Loading no longer covers the whole screen with a spinner, on phones too: only the part that is waiting shows a skeleton shaped like its content, under the real top bar, and fades into the content when it arrives. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RhmDdrN5hgWrgRcFLAMsV6 --- README.md | 5 + .../com/ganjoor/android/data/Dictionary.kt | 11 +- .../com/ganjoor/android/ui/CategoryScreen.kt | 31 +- .../com/ganjoor/android/ui/ColumnBrowser.kt | 669 ++++++++++++++++++ .../java/com/ganjoor/android/ui/GanjoorApp.kt | 126 +++- .../main/java/com/ganjoor/android/ui/Load.kt | 35 +- .../java/com/ganjoor/android/ui/PoemScreen.kt | 183 ++++- .../com/ganjoor/android/ui/PoetsScreen.kt | 43 +- .../java/com/ganjoor/android/ui/Settings.kt | 4 + .../java/com/ganjoor/android/ui/Skeleton.kt | 147 ++++ .../java/com/ganjoor/android/ui/WordSheet.kt | 80 ++- app/src/main/res/drawable/ic_menu_open.xml | 12 + app/src/main/res/values-fa/strings.xml | 4 + app/src/main/res/values-ur/strings.xml | 4 + app/src/main/res/values/strings.xml | 4 + .../com/ganjoor/android/ColumnUrlsTest.kt | 34 + 16 files changed, 1294 insertions(+), 98 deletions(-) create mode 100644 app/src/main/java/com/ganjoor/android/ui/ColumnBrowser.kt create mode 100644 app/src/main/java/com/ganjoor/android/ui/Skeleton.kt create mode 100644 app/src/main/res/drawable/ic_menu_open.xml create mode 100644 app/src/test/java/com/ganjoor/android/ColumnUrlsTest.kt diff --git a/README.md b/README.md index 755eb2e..29a29b4 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,11 @@ bar, and any span of text can be selected and copied the usual way. The whole interface lays out and navigates right-to-left, whichever UI language is chosen. +On a tablet or an unfolded foldable (600dp and wider) the poets become a narrow column on the right, +with the open poet's books, chapters and poems in columns beside it and the page in the rest of the +screen. The columns narrow while a poem is open and can be hidden for a reader view. The dictionary +opens in a panel to the left of the poem instead of over it. Phones are unchanged. + ## Fonts | Font | Used for | Licence | diff --git a/app/src/main/java/com/ganjoor/android/data/Dictionary.kt b/app/src/main/java/com/ganjoor/android/data/Dictionary.kt index 8ce40c9..4e0e963 100644 --- a/app/src/main/java/com/ganjoor/android/data/Dictionary.kt +++ b/app/src/main/java/com/ganjoor/android/data/Dictionary.kt @@ -261,7 +261,11 @@ internal fun letterOverlap(a: String, b: String): Float { } /** The whole word surrounding [index], for turning a tap into something to look up. */ -internal fun wordAt(text: String, index: Int): String? { +internal fun wordAt(text: String, index: Int): String? = + wordRangeAt(text, index)?.let { text.substring(it) } + +/** Where in [text] the word [wordAt] finds lies, so the reader can see which word was looked up. */ +internal fun wordRangeAt(text: String, index: Int): IntRange? { if (text.isEmpty()) return null val at = index.coerceIn(0, text.length - 1) if (!text[at].isWordChar()) return null @@ -269,7 +273,10 @@ internal fun wordAt(text: String, index: Int): String? { while (start > 0 && text[start - 1].isWordChar()) start-- var end = at while (end < text.length - 1 && text[end + 1].isWordChar()) end++ - return text.substring(start, end + 1).trim(ZWNJ).takeIf { it.length > 1 } + // A joiner at either edge belongs to the neighbour, not to the word. + while (start <= end && text[start] == ZWNJ) start++ + while (end >= start && text[end] == ZWNJ) end-- + return (start..end).takeIf { end - start + 1 > 1 } } private fun Char.isWordChar() = this in '؀'..'ۿ' || this == ZWNJ diff --git a/app/src/main/java/com/ganjoor/android/ui/CategoryScreen.kt b/app/src/main/java/com/ganjoor/android/ui/CategoryScreen.kt index 3f408b6..8486df6 100644 --- a/app/src/main/java/com/ganjoor/android/ui/CategoryScreen.kt +++ b/app/src/main/java/com/ganjoor/android/ui/CategoryScreen.kt @@ -56,7 +56,12 @@ fun CategoryScreen( // System Back climbs the tree too, not the visit history. BackHandler(onBack = onUp) - Load(key = fullUrl, block = { Ganjoor.category(fullUrl) }) { cat -> + Load( + key = fullUrl, + block = { Ganjoor.category(fullUrl) }, + // The bar stays up with Back already working; only the list waits. + placeholder = { LoadingCategory(onUp) }, + ) { cat -> // First lines are a separate, optional call; the list shows up without waiting for it. var excerpts by remember(cat.id) { mutableStateOf(emptyMap()) } LaunchedEffect(cat.id) { @@ -124,12 +129,32 @@ fun CategoryScreen( } } +/** The category screen while its list is on the way: the real top bar over a skeleton list. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun LoadingCategory(onUp: () -> Unit) { + Scaffold( + topBar = { + TopAppBar( + title = {}, + navigationIcon = { + IconButton(onClick = onUp) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back)) + } + }, + ) + } + ) { insets -> + SkeletonList(Modifier.padding(insets)) + } +} + /** A poet's root URL is a single segment (`/hafez`); anything deeper is one of their books. */ -private fun poetSlug(fullUrl: String): String? = +internal fun poetSlug(fullUrl: String): String? = fullUrl.trim('/').takeIf { it.isNotEmpty() && !it.contains('/') } @Composable -private fun PoetDownloadAction(slug: String) { +internal fun PoetDownloadAction(slug: String) { val progress = Downloads.running[slug] val saved = remember(slug, Downloads.revision) { Offline.isSaved(slug) } diff --git a/app/src/main/java/com/ganjoor/android/ui/ColumnBrowser.kt b/app/src/main/java/com/ganjoor/android/ui/ColumnBrowser.kt new file mode 100644 index 0000000..d49576e --- /dev/null +++ b/app/src/main/java/com/ganjoor/android/ui/ColumnBrowser.kt @@ -0,0 +1,669 @@ +package com.ganjoor.android.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SmallFloatingActionButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.ganjoor.android.R +import com.ganjoor.android.data.CatEntry +import com.ganjoor.android.data.Category +import com.ganjoor.android.data.Ganjoor +import com.ganjoor.android.data.LocalPinnedPoets +import com.ganjoor.android.data.Manifest +import com.ganjoor.android.data.PoetRef +import com.ganjoor.android.data.orderedEntries +import com.ganjoor.android.data.parentUrl + +/* + * The tablet and unfolded-foldable layout. Right to left, as the app reads: a narrow column of + * poets, then a column for each level of the open book (its books, a book's chapters, a + * chapter's poems), then the open page in whatever room is left. Phones never see any of this; + * GanjoorApp only switches to it at 600dp and wider. + */ + +/** + * What the columns have loaded and where each one is scrolled to. + * + * Every browse step is still a navigation destination, so Back and the phone layout keep working + * unchanged; this lives above the NavHost so that moving from one destination to the next + * doesn't throw away the columns the reader can still see. + */ +@Stable +class BrowserState { + var manifest by mutableStateOf(null) + val categories = mutableStateMapOf() + val excerpts = mutableStateMapOf>() + val railState = LazyListState() + private val listStates = HashMap() + private val widths = HashMap>() + + fun listState(url: String): LazyListState = listStates.getOrPut(url) { LazyListState() } + + /** + * A column's width in dp, kept here rather than in the column: each tap is a new destination, + * and a width remembered by the column would open it out from nothing again every time. + */ + fun width(url: String): Animatable = widths.getOrPut(url) { Animatable(0f) } + + suspend fun category(url: String): Category = + categories[url] ?: Ganjoor.category(url).also { categories[url] = it } +} + +/** + * The categories that get a column on the way to [url], poet first: + * `/saadi/golestan/bab1/sh1` → `/saadi`, `/saadi/golestan`, `/saadi/golestan/bab1`. + * A poem is read rather than listed, so its own URL gets no column; a category's does. + */ +internal fun columnUrls(url: String, isPoem: Boolean): List { + val segments = url.trim('/').split('/').filter { it.isNotEmpty() } + val depth = if (isPoem) segments.size - 1 else segments.size + return (1..depth).map { "/" + segments.take(it).joinToString("/") } +} + +/** + * Newest column widest; older ones give their room to the page. While a poem is open every + * column steps back further, so the reading gets most of the screen. + */ +private fun columnWidth(distance: Int, reading: Boolean): Dp = when { + distance == 0 && !reading -> 224.dp + distance == 0 || distance == 1 && !reading -> 168.dp + else -> 132.dp +} + +private fun same(a: String?, b: String?) = a != null && b != null && a.trimEnd('/') == b.trimEnd('/') + +private val CatEntry.url + get() = when (this) { + is CatEntry.Chapter -> category.fullUrl + is CatEntry.Poem -> poem.fullUrl + } + +/** + * The columns beside [content]. [expanded] (840dp and up) shows up to three list columns; + * narrower, only the newest one, with a way back up in its header. + * + * [content] gets the button that hides or shows the columns, to put in its own top bar. + */ +@Composable +fun ColumnBrowser( + state: BrowserState, + url: String, + isPoem: Boolean, + expanded: Boolean, + onPoet: (String) -> Unit, + onCategory: (String) -> Unit, + onPoem: (String) -> Unit, + content: @Composable (toggle: @Composable () -> Unit) -> Unit, +) { + val settings = LocalSettings.current + val hidden = settings.value.columnsHidden + val columns = remember(url, isPoem) { columnUrls(url, isPoem) } + // What is selected in each column: the next step of the path, down to the open poem. + val path = if (isPoem) columns + url else columns + val shown = columns.takeLast(if (expanded) 3 else 1) + + Row(Modifier.fillMaxSize()) { + // Folding away slides the columns off to the right, where they live. + AnimatedVisibility( + visible = !hidden, + enter = expandHorizontally(expandFrom = Alignment.Start) + fadeIn(), + exit = shrinkHorizontally(shrinkTowards = Alignment.Start) + fadeOut(), + ) { + Row(Modifier.fillMaxHeight()) { + PoetRail( + state = state, + selected = path.firstOrNull(), + compact = shown.size > 1 || isPoem, + onPoet = onPoet, + ) + VerticalDivider() + shown.forEachIndexed { index, columnUrl -> + key(columnUrl) { + // Only the first visible column can have hidden ancestors to climb back + // to, and a poet's own column climbs to the rail, which is already there. + val up = parentUrl(columnUrl) + ?.takeIf { index == 0 && columns.size > shown.size } + ListColumn( + state = state, + url = columnUrl, + selected = path.getOrNull(path.indexOf(columnUrl) + 1), + distance = shown.size - 1 - index, + reading = isPoem, + onUp = up?.let { parent -> { onCategory(parent) } }, + onCategory = onCategory, + onPoem = onPoem, + ) + VerticalDivider() + } + } + } + } + Box(Modifier.weight(1f).fillMaxHeight()) { + content { + // Hiding is in the top bar; bringing them back is the floating button below, + // which is there whenever they are hidden. + if (!hidden) ColumnsToggle { settings.update { it.copy(columnsHidden = true) } } + } + // Reader view: the page has the whole screen, and one button, at the edge the + // columns went to, brings them back as they were. + AnimatedVisibility( + visible = hidden, + enter = scaleIn() + fadeIn(), + exit = scaleOut() + fadeOut(), + modifier = Modifier + .align(Alignment.BottomStart) + .navigationBarsPadding() + .padding(16.dp), + ) { + SmallFloatingActionButton( + onClick = { settings.update { it.copy(columnsHidden = false) } }, + ) { + Icon(Icons.Default.Menu, stringResource(R.string.show_columns)) + } + } + } + } +} + +/** Folds the columns away so the page has the whole screen (reader view). */ +@Composable +private fun ColumnsToggle(onHide: () -> Unit) { + IconButton(onClick = onHide) { + Icon(painterResource(R.drawable.ic_menu_open), stringResource(R.string.hide_columns)) + } +} + +/** Every poet, portrait over name, in the same order the home screen uses. */ +@Composable +private fun PoetRail( + state: BrowserState, + selected: String?, + compact: Boolean, + onPoet: (String) -> Unit, +) { + var attempt by remember { mutableIntStateOf(0) } + var failed by remember { mutableStateOf(false) } + LaunchedEffect(attempt) { + if (state.manifest == null) { + failed = false + runCatching { Ganjoor.manifest() } + .onSuccess { state.manifest = it } + .onFailure { failed = true } + } + } + + val sort = LocalSettings.current.value.poetSort + val pinned = LocalPinnedPoets.current + val manifest = state.manifest + val poets = remember(manifest, sort, pinned.items.toList()) { + manifest?.let { orderPoets(it.poets, sort, pinned.items) }.orEmpty() + } + val width by animateDpAsState(if (compact) 80.dp else 96.dp, label = "rail width") + + // Keep the open poet in view, without yanking the rail back while the reader scrolls it. + LaunchedEffect(selected, poets.size) { + val index = poets.indexOfFirst { same(it.fullUrl, selected) } + if (index >= 0 && state.railState.layoutInfo.visibleItemsInfo.none { it.index == index }) { + state.railState.scrollToItem(index) + } + } + + Surface( + color = MaterialTheme.colorScheme.surfaceContainer, + modifier = Modifier.width(width).fillMaxHeight(), + ) { + when { + manifest != null -> LazyColumn( + state = state.railState, + modifier = Modifier.fillMaxSize().statusBarsPadding(), + contentPadding = PaddingValues(horizontal = 4.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + items(poets, key = { it.id }) { poet -> + RailPoet( + poet = poet, + selected = same(poet.fullUrl, selected), + compact = compact, + onClick = { onPoet(poet.fullUrl) }, + ) + } + } + + failed -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + TextButton(onClick = { attempt++ }) { Text(stringResource(R.string.retry)) } + } + + else -> SkeletonRail(Modifier.statusBarsPadding(), disc = if (compact) 44.dp else 56.dp) + } + } +} + +@Composable +private fun RailPoet(poet: PoetRef, selected: Boolean, compact: Boolean, onClick: () -> Unit) { + val colors = MaterialTheme.colorScheme + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(if (selected) colors.secondaryContainer else Color.Transparent) + .selectable(selected = selected, role = Role.Tab, onClick = onClick) + .padding(horizontal = 4.dp, vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + // A ring round the open poet's portrait, so the choice doesn't rest on the tile alone. + val ring = if (selected) { + Modifier.border(2.dp, colors.primary, CircleShape).padding(4.dp) + } else { + Modifier + } + Box( + modifier = Modifier + .size(if (compact) 44.dp else 56.dp) + .then(ring) + .clip(CircleShape) + .background(colors.secondaryContainer), + contentAlignment = Alignment.Center, + ) { + // The initial sits underneath, as on the home screen, so a missing portrait isn't a hole. + Text(poet.nickname.take(1), color = colors.onSecondaryContainer) + AsyncImage( + model = poet.imageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } + Text( + text = poet.nickname, + style = MaterialTheme.typography.labelMedium, + color = if (selected) colors.onSecondaryContainer else colors.onSurface, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } +} + +/** + * One level of the open book: a category's chapters and poems, with [selected] marked. + * [distance] counts the columns to its left; the further back, the narrower and terser it gets. + */ +@Composable +private fun ListColumn( + state: BrowserState, + url: String, + selected: String?, + distance: Int, + reading: Boolean, + onUp: (() -> Unit)?, + onCategory: (String) -> Unit, + onPoem: (String) -> Unit, +) { + val target = columnWidth(distance, reading) + // From nothing when the column first appears, so it opens out rather than popping in; after + // that, from wherever it was, so an older column narrows as a new one opens. + val width = state.width(url) + LaunchedEffect(target) { + width.animateTo(target.value, tween(300, easing = FastOutSlowInEasing)) + } + + var attempt by remember(url) { mutableIntStateOf(0) } + var failed by remember(url) { mutableStateOf(false) } + LaunchedEffect(url, attempt) { + failed = false + runCatching { state.category(url) }.onFailure { failed = true } + } + + val cat = state.categories[url] + // Older columns, and every column while a poem is open, drop first lines and wrap titles. + val terse = distance > 0 || reading + // First lines only where there's room to show them: the newest column. + LaunchedEffect(cat?.id, terse) { + if (cat != null && !terse && cat.poems.isNotEmpty() && cat.id !in state.excerpts) { + state.excerpts[cat.id] = runCatching { Ganjoor.excerpts(cat.id) }.getOrDefault(emptyMap()) + } + } + + Surface( + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.width(width.value.dp).fillMaxHeight().clipToBounds(), + ) { + Column(Modifier.fillMaxSize().statusBarsPadding()) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 56.dp) + .padding(horizontal = if (terse) 8.dp else 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (onUp != null) { + IconButton(onClick = onUp) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back)) + } + } + Text( + text = cat?.title.orEmpty(), + style = if (terse) MaterialTheme.typography.titleSmall + else MaterialTheme.typography.titleMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } + HorizontalDivider() + + // Only this column waits: a skeleton of its rows, fading into the list when it lands. + Crossfade(targetState = cat, label = "column") { loaded -> + when { + loaded != null -> { + val cat = loaded + val entries = remember(cat) { orderedEntries(cat) } + val excerpts = if (terse) emptyMap() else state.excerpts[cat.id].orEmpty() + val listState = state.listState(url) + LaunchedEffect(selected, entries) { + val index = entries.indexOfFirst { same(it.url, selected) } + if (index >= 0 && listState.layoutInfo.visibleItemsInfo.none { it.index == index }) { + listState.scrollToItem(index) + } + } + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + items( + items = entries, + key = { entry -> + when (entry) { + is CatEntry.Chapter -> "c${entry.category.id}" + is CatEntry.Poem -> "p${entry.poem.id}" + } + }, + ) { entry -> + ColumnRow( + entry = entry, + selected = same(entry.url, selected), + terse = terse, + excerpt = (entry as? CatEntry.Poem)?.let { excerpts[it.poem.id] }, + onClick = { + when (entry) { + is CatEntry.Chapter -> onCategory(entry.category.fullUrl) + is CatEntry.Poem -> onPoem(entry.poem.fullUrl) + } + }, + ) + } + } + } + + failed -> Column(Modifier.padding(16.dp)) { + Text( + text = stringResource(R.string.load_failed), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + TextButton(onClick = { attempt++ }) { Text(stringResource(R.string.retry)) } + } + + else -> SkeletonList(rows = 10, twoLines = !terse) + } + } + } + } +} + +@Composable +private fun ColumnRow( + entry: CatEntry, + selected: Boolean, + terse: Boolean, + excerpt: String?, + onClick: () -> Unit, +) { + val colors = MaterialTheme.colorScheme + val title = when (entry) { + is CatEntry.Chapter -> entry.category.title + is CatEntry.Poem -> entry.poem.title + } + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(if (selected) colors.secondaryContainer else Color.Transparent) + .selectable(selected = selected, role = Role.Tab, onClick = onClick) + .heightIn(min = 48.dp) + .padding(horizontal = if (terse) 8.dp else 12.dp, vertical = 8.dp), + verticalArrangement = Arrangement.Center, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = title, + style = if (terse) MaterialTheme.typography.bodyMedium + else MaterialTheme.typography.bodyLarge, + color = if (selected) colors.onSecondaryContainer else colors.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + // A chapter opens another column; a poem opens on the page. + if (!terse && entry is CatEntry.Chapter) { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = colors.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + } + excerpt?.takeIf { it.isNotBlank() }?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = if (selected) colors.onSecondaryContainer else colors.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +/** + * The page for an open poet, book or chapter: its description, then what it holds as cards. + * The column beside it lists the same things; this is what fills the room the phone's list did. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CategoryOverview( + state: BrowserState, + fullUrl: String, + toggle: @Composable () -> Unit, + onUp: () -> Unit, + onHome: () -> Unit, + onCategory: (String) -> Unit, + onPoem: (String) -> Unit, +) { + BackHandler(onBack = onUp) + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = state.categories[fullUrl]?.title.orEmpty(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + navigationIcon = { + Row { + toggle() + IconButton(onClick = onUp) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back)) + } + } + }, + actions = { + HomeAction(onHome) + poetSlug(fullUrl)?.let { PoetDownloadAction(it) } + ReadingSettingsAction() + }, + ) + } + ) { insets -> + Load( + key = fullUrl, + block = { state.category(fullUrl) }, + placeholder = { + SkeletonCards(Modifier.padding(top = insets.calculateTopPadding()).padding(horizontal = 12.dp)) + }, + ) { cat -> + LaunchedEffect(cat.id) { + if (cat.poems.isNotEmpty() && cat.id !in state.excerpts) { + state.excerpts[cat.id] = + runCatching { Ganjoor.excerpts(cat.id) }.getOrDefault(emptyMap()) + } + } + val entries = remember(cat) { orderedEntries(cat) } + val excerpts = state.excerpts[cat.id].orEmpty() + + BoxWithConstraints(Modifier.fillMaxSize().padding(top = insets.calculateTopPadding())) { + // The same centred measure as a poem, a little wider for the cards. + val side = maxOf(24.dp, (maxWidth - 760.dp) / 2) + LazyVerticalGrid( + columns = GridCells.Adaptive(200.dp), + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = side, + end = side, + top = 8.dp, + bottom = insets.calculateBottomPadding() + 32.dp, + ), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + cat.description?.takeIf { it.isNotBlank() }?.let { description -> + item(span = { GridItemSpan(maxLineSpan) }) { + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp), + ) + } + } + items(entries) { entry -> + Card( + onClick = { + when (entry) { + is CatEntry.Chapter -> onCategory(entry.category.fullUrl) + is CatEntry.Poem -> onPoem(entry.poem.fullUrl) + } + }, + ) { + Column(Modifier.fillMaxWidth().padding(12.dp)) { + Text( + text = when (entry) { + is CatEntry.Chapter -> entry.category.title + is CatEntry.Poem -> entry.poem.title + }, + style = MaterialTheme.typography.titleMedium, + ) + (entry as? CatEntry.Poem)?.let { excerpts[it.poem.id] } + ?.takeIf { it.isNotBlank() } + ?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/ganjoor/android/ui/GanjoorApp.kt b/app/src/main/java/com/ganjoor/android/ui/GanjoorApp.kt index b5a5ebf..71519e4 100644 --- a/app/src/main/java/com/ganjoor/android/ui/GanjoorApp.kt +++ b/app/src/main/java/com/ganjoor/android/ui/GanjoorApp.kt @@ -1,12 +1,20 @@ package com.ganjoor.android.ui import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.navigation.NavController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -71,6 +79,8 @@ private fun NavController.goUp(fromUrl: String) { fun GanjoorApp() { val nav = rememberNavController() var settingsOpen by rememberSaveable { mutableStateOf(false) } + // Above the NavHost, so the large-screen columns survive moving between destinations. + val browser = remember { BrowserState() } CompositionLocalProvider( LocalOpenReadingSettings provides { settingsOpen = true }, @@ -83,15 +93,33 @@ fun GanjoorApp() { nav.navigate(AssistantRoute) }, ) { + // Tablets and unfolded foldables browse in columns (ColumnBrowser); phones, and a + // foldable when folded, keep one screen at a time. Same routes either way, so unfolding + // in the middle of a poem keeps the poem. + BoxWithConstraints(Modifier.fillMaxSize()) { + // Read through state everywhere below: the transition and destination lambdas belong to + // the nav graph, which outlives this composition, and folding or unfolding must reach them. + val wide by rememberUpdatedState(maxWidth >= 600.dp) + // Up to three list columns beside the poets; below this, only the newest one. + val expanded by rememberUpdatedState(maxWidth >= 840.dp) NavHost( navController = nav, startDestination = PoetsRoute, // Start/End rather than Left/Right, so going deeper always moves against the reading - // direction — leftwards here, since the app lays out right-to-left. - enterTransition = { slideIntoContainer(SlideDirection.Start) }, - exitTransition = { slideOutOfContainer(SlideDirection.Start) }, - popEnterTransition = { slideIntoContainer(SlideDirection.End) }, - popExitTransition = { slideOutOfContainer(SlideDirection.End) }, + // direction — leftwards here, since the app lays out right-to-left. In columns the + // page changes in place: sliding the whole screen would drag the columns with it. + enterTransition = { + if (wide) EnterTransition.None else slideIntoContainer(SlideDirection.Start) + }, + exitTransition = { + if (wide) ExitTransition.None else slideOutOfContainer(SlideDirection.Start) + }, + popEnterTransition = { + if (wide) EnterTransition.None else slideIntoContainer(SlideDirection.End) + }, + popExitTransition = { + if (wide) ExitTransition.None else slideOutOfContainer(SlideDirection.End) + }, ) { composable { PoetsScreen( @@ -103,34 +131,73 @@ fun GanjoorApp() { } composable { entry -> val url = entry.toRoute().url - CategoryScreen( - fullUrl = url, - onUp = { nav.goUp(url) }, - onHome = { nav.goHome() }, - onCategory = { nav.open(CategoryRoute(it)) }, - onPoem = { nav.open(PoemRoute(it)) }, - ) + if (wide) { + ColumnBrowser( + state = browser, + url = url, + isPoem = false, + expanded = expanded, + onPoet = { nav.open(CategoryRoute(it)) }, + onCategory = { nav.open(CategoryRoute(it)) }, + onPoem = { nav.open(PoemRoute(it)) }, + ) { toggle -> + CategoryOverview( + state = browser, + fullUrl = url, + toggle = toggle, + onUp = { nav.goUp(url) }, + onHome = { nav.goHome() }, + onCategory = { nav.open(CategoryRoute(it)) }, + onPoem = { nav.open(PoemRoute(it)) }, + ) + } + } else { + CategoryScreen( + fullUrl = url, + onUp = { nav.goUp(url) }, + onHome = { nav.goHome() }, + onCategory = { nav.open(CategoryRoute(it)) }, + onPoem = { nav.open(PoemRoute(it)) }, + ) + } } composable { entry -> val route = entry.toRoute() - PoemScreen( - fullUrl = route.url, - // Reading on through a divan keeps the origin, so Back still lands where - // you started rather than in whichever section you drifted into. - onUp = { if (route.fromBookmarks) nav.navigateUp() else nav.goUp(route.url) }, - onHome = { nav.goHome() }, - onPoem = { url -> - if (route.fromBookmarks) { - nav.navigate(PoemRoute(url, fromBookmarks = true)) { - popUpTo { inclusive = true } + val poem: @Composable (Boolean, (@Composable () -> Unit)?) -> Unit = { wideText, toggle -> + PoemScreen( + fullUrl = route.url, + // Reading on through a divan keeps the origin, so Back still lands where + // you started rather than in whichever section you drifted into. + onUp = { if (route.fromBookmarks) nav.navigateUp() else nav.goUp(route.url) }, + onHome = { nav.goHome() }, + onPoem = { url -> + if (route.fromBookmarks) { + nav.navigate(PoemRoute(url, fromBookmarks = true)) { + popUpTo { inclusive = true } + } + } else { + nav.open(PoemRoute(url)) } - } else { - nav.open(PoemRoute(url)) - } - }, - // Tapping a breadcrumb leaves the saved list behind and starts browsing. - onCategory = { nav.open(CategoryRoute(it)) }, - ) + }, + // Tapping a breadcrumb leaves the saved list behind and starts browsing. + onCategory = { nav.open(CategoryRoute(it)) }, + wide = wideText, + navigationToggle = toggle, + ) + } + if (wide) { + ColumnBrowser( + state = browser, + url = route.url, + isPoem = true, + expanded = expanded, + onPoet = { nav.open(CategoryRoute(it)) }, + onCategory = { nav.open(CategoryRoute(it)) }, + onPoem = { nav.open(PoemRoute(it)) }, + ) { toggle -> poem(true, toggle) } + } else { + poem(false, null) + } } composable { entry -> SearchScreen( @@ -158,6 +225,7 @@ fun GanjoorApp() { AboutScreen(onUp = { nav.navigateUp() }, onHome = { nav.goHome() }) } } + } // Inside the provider: the sheet reads LocalOpenAbout, so it has to be in scope. if (settingsOpen) ReadingSettingsSheet(onDismiss = { settingsOpen = false }) diff --git a/app/src/main/java/com/ganjoor/android/ui/Load.kt b/app/src/main/java/com/ganjoor/android/ui/Load.kt index ca1e701..dd82ef5 100644 --- a/app/src/main/java/com/ganjoor/android/ui/Load.kt +++ b/app/src/main/java/com/ganjoor/android/ui/Load.kt @@ -1,12 +1,17 @@ package com.ganjoor.android.ui +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.core.tween +import androidx.compose.animation.togetherWith 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.fillMaxSize +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -27,15 +32,36 @@ import com.ganjoor.android.data.NotDownloaded /** * Fetches [block] whenever [key] changes and renders loading / error / content. * + * While loading it shows [placeholder]: a skeleton shaped like what is coming, never a spinner + * over the whole page, so only the part that is actually waiting looks like it. The content + * fades in over it when it arrives. + * * ponytail: no ViewModel, so going back re-fetches — which the disk cache makes nearly free. * Promote to a ViewModel when a screen gains state worth surviving rotation. */ @Composable -fun Load(key: Any?, block: suspend () -> T, content: @Composable (T) -> Unit) { +fun Load( + key: Any?, + block: suspend () -> T, + placeholder: @Composable () -> Unit = { SkeletonList(Modifier.statusBarsPadding()) }, + content: @Composable (T) -> Unit, +) { var attempt by remember(key) { mutableIntStateOf(0) } val result by produceState?>(null, key, attempt) { value = runCatching { block() } } - result.let { outcome -> + AnimatedContent( + targetState = result, + transitionSpec = { fadeIn(tween(220)) togetherWith fadeOut(tween(120)) }, + // Fade between loading, failed and loaded, not on every new value of the same state. + contentKey = { outcome -> + when { + outcome == null -> 0 + outcome.isFailure -> 1 + else -> 2 + } + }, + label = "load", + ) { outcome -> when { // Painted explicitly: on the category and poem screens Load sits outside the // Scaffold, so while loading nothing else fills the window and the bare window @@ -44,9 +70,8 @@ fun Load(key: Any?, block: suspend () -> T, content: @Composable (T) -> Unit modifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.background), - contentAlignment = Alignment.Center, ) { - CircularProgressIndicator() + placeholder() } outcome.isFailure -> Column( diff --git a/app/src/main/java/com/ganjoor/android/ui/PoemScreen.kt b/app/src/main/java/com/ganjoor/android/ui/PoemScreen.kt index 504eb97..9fddf57 100644 --- a/app/src/main/java/com/ganjoor/android/ui/PoemScreen.kt +++ b/app/src/main/java/com/ganjoor/android/ui/PoemScreen.kt @@ -1,9 +1,18 @@ package com.ganjoor.android.ui import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkHorizontally import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -47,13 +56,15 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.ganjoor.android.R import com.ganjoor.android.data.Bookmark import com.ganjoor.android.data.breadcrumbs -import com.ganjoor.android.data.wordAt +import com.ganjoor.android.data.wordRangeAt import com.ganjoor.android.data.Ganjoor import com.ganjoor.android.data.LocalAssistant import com.ganjoor.android.data.LocalBookmarks @@ -71,14 +82,23 @@ fun PoemScreen( onHome: () -> Unit, onPoem: (String) -> Unit, onCategory: (String) -> Unit, + /** Large screens: the text keeps a centred reading measure and couplets may sit on one line. */ + wide: Boolean = false, + /** Large screens: the button that hides or shows the columns, placed before the back arrow. */ + navigationToggle: (@Composable () -> Unit)? = null, ) { BackHandler(onBack = onUp) // The couplet travels with the word: the dictionary sheet is the one gesture every reader // finds, so the couplet's own actions live at its foot rather than behind a tap between words. - var tapped by remember { mutableStateOf?>(null) } + var tapped by remember { mutableStateOf(null) } - Load(key = fullUrl, block = { Ganjoor.poem(fullUrl) }) { poem -> + Load( + key = fullUrl, + block = { Ganjoor.poem(fullUrl) }, + // The bar stays up with Back already working; only the text waits. + placeholder = { LoadingPoem(wide, onUp, navigationToggle) }, + ) { poem -> val prefs = LocalSettings.current.value val style = readingStyle(prefs.font, prefs.fontSize, prefs.fontWeight.weight) val couplets = remember(poem) { poem.verses.couplets() } @@ -93,13 +113,20 @@ fun PoemScreen( } val here = siblings.indexOfFirst { it.fullUrl == fullUrl } + // On a large screen the dictionary opens beside the poem, on the left, instead of as a + // sheet over it: the text moves over to make room and nothing of it is covered. + Row(Modifier.fillMaxSize()) { + Box(Modifier.weight(1f)) { Scaffold( topBar = { TopAppBar( title = { Text(poem.title, maxLines = 1, overflow = TextOverflow.Ellipsis) }, navigationIcon = { - IconButton(onClick = onUp) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back)) + Row { + navigationToggle?.invoke() + IconButton(onClick = onUp) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back)) + } } }, actions = { @@ -123,12 +150,18 @@ fun PoemScreen( // here: Compose 1.10 stopped routing SelectionContainer through LocalTextToolbar, so // a custom TextToolbar is never asked to show. ACTION_PROCESS_TEXT goes round that, // and reaches every other app's selection menu as a side effect. + BoxWithConstraints { + // On a large screen the text keeps a reading measure in the middle of whatever room + // the columns leave it, and once that measure is wide enough the two hemistichs of a + // couplet share a line, as ganjoor.net sets them on a desktop. + val side = if (wide) maxOf(20.dp, (maxWidth - 680.dp) / 2) else 20.dp + val sideBySide = wide && maxWidth - side * 2 >= 640.dp SelectionContainer { LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues( - start = 20.dp, - end = 20.dp, + start = side, + end = side, top = insets.calculateTopPadding() + 8.dp, bottom = insets.calculateBottomPadding() + 32.dp, ), @@ -151,9 +184,11 @@ fun PoemScreen( Couplet( couplet = couplet, style = style, + sideBySide = sideBySide, showSummaries = prefs.showSummaries, source = Bookmark(fullUrl, poem.title, poem.fullTitle), - onWord = { word, passage -> tapped = word to passage }, + onWord = { tapped = it }, + tapped = tapped, ) } @@ -183,15 +218,64 @@ fun PoemScreen( } } } + } + } + } + if (wide) { + AnimatedVisibility( + visible = tapped != null, + enter = expandHorizontally() + fadeIn(), + exit = shrinkHorizontally() + fadeOut(), + ) { + tapped?.let { tap -> + WordPanel( + word = tap.word, + passage = tap.passage, + onDismiss = { tapped = null }, + ) + } + } + } } } - tapped?.let { (word, passage) -> - WordSheet( - word = word, - passage = passage, - onDismiss = { tapped = null }, - ) + if (!wide) { + tapped?.let { tap -> + WordSheet( + word = tap.word, + passage = tap.passage, + onDismiss = { tapped = null }, + ) + } + } +} + +/** A word someone tapped: what to look up, the couplet it came from, and where it sits in its verse. */ +private data class WordTap(val word: String, val passage: Bookmark, val verse: Int, val range: IntRange) + +/** The poem screen while its poem is on the way: the real top bar, and the text as a skeleton. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun LoadingPoem(wide: Boolean, onUp: () -> Unit, navigationToggle: (@Composable () -> Unit)?) { + Scaffold( + topBar = { + TopAppBar( + title = {}, + navigationIcon = { + Row { + navigationToggle?.invoke() + IconButton(onClick = onUp) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back)) + } + } + }, + ) + } + ) { insets -> + BoxWithConstraints(Modifier.fillMaxSize().padding(insets)) { + val side = if (wide) maxOf(20.dp, (maxWidth - 680.dp) / 2) else 20.dp + SkeletonPoem(Modifier.padding(start = side, end = side, top = 16.dp)) + } } } @@ -271,21 +355,44 @@ private fun Couplet( style: androidx.compose.ui.text.TextStyle, showSummaries: Boolean, source: Bookmark, - onWord: (String, Bookmark) -> Unit, + onWord: (WordTap) -> Unit, + tapped: WordTap?, + sideBySide: Boolean = false, ) { // Tap, not long-press: long-press belongs to the text selection this sits inside. var actionsOpen by remember(couplet) { mutableStateOf(false) } val passage = source.copy(excerpt = couplet.joinToString("\n") { it.text }) + // Only a true Right+Left pair shares a line; centred verses and prose keep their own. + val oneLine = sideBySide && couplet.size == 2 && + couplet[0].position == Verse.RIGHT && couplet[1].position == Verse.LEFT + Column(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp)) { - couplet.forEach { verse -> - VerseText( - verse = verse, - style = style, - onWord = { onWord(it, passage) }, - // A tap that lands between words still opens the couplet's own actions. - onElsewhere = { actionsOpen = !actionsOpen }, - ) + if (oneLine) { + Row(modifier = Modifier.fillMaxWidth()) { + couplet.forEachIndexed { index, verse -> + if (index > 0) Spacer(Modifier.width(32.dp)) + VerseText( + verse = verse, + style = style, + onWord = { range -> onWord(tapOf(verse, range, passage)) }, + onElsewhere = { actionsOpen = !actionsOpen }, + highlight = tapped?.takeIf { it.verse == verse.vOrder }?.range, + modifier = Modifier.weight(1f), + ) + } + } + } else { + couplet.forEach { verse -> + VerseText( + verse = verse, + style = style, + onWord = { range -> onWord(tapOf(verse, range, passage)) }, + // A tap that lands between words still opens the couplet's own actions. + onElsewhere = { actionsOpen = !actionsOpen }, + highlight = tapped?.takeIf { it.verse == verse.vOrder }?.range, + ) + } } if (showSummaries) { couplet.firstNotNullOfOrNull { it.coupletSummary } @@ -346,18 +453,36 @@ private fun Couplet( * One hemistich. Tapping a word looks it up; tapping between words falls through to the * couplet's save and copy actions, so both live on the same gesture without fighting. */ +private fun tapOf(verse: Verse, range: IntRange, passage: Bookmark) = + WordTap(verse.text.substring(range), passage, verse.vOrder, range) + @Composable private fun VerseText( verse: Verse, style: androidx.compose.ui.text.TextStyle, - onWord: (String) -> Unit, + onWord: (IntRange) -> Unit, onElsewhere: () -> Unit, + /** The word being looked up, marked so the reader can see which one it was. */ + highlight: IntRange? = null, + modifier: Modifier = Modifier, ) { var layout by remember(verse.text) { mutableStateOf(null) } val fontSizePx = with(LocalDensity.current) { style.fontSize.toPx() } + val mark = SpanStyle( + background = MaterialTheme.colorScheme.secondaryContainer, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + val text = remember(verse.text, highlight, mark) { + buildAnnotatedString { + append(verse.text) + if (highlight != null && highlight.last < verse.text.length) { + addStyle(mark, highlight.first, highlight.last + 1) + } + } + } Text( - text = verse.text, + text = text, style = style, textAlign = when (verse.position) { Verse.RIGHT -> TextAlign.Start @@ -367,7 +492,7 @@ private fun VerseText( else -> TextAlign.Justify }, onTextLayout = { layout = it }, - modifier = Modifier + modifier = modifier .fillMaxWidth() .pointerInput(verse.text) { detectTapGestures { position -> @@ -379,7 +504,7 @@ private fun VerseText( } /** - * The word actually under [position], or null if the tap missed the glyphs. + * Where the word actually under [position] lies, or null if the tap missed the glyphs. * * getOffsetForPosition alone isn't enough: nastaliq is set with 2.4x leading, so most of a line * box is empty space above the glyphs, and a tap there clamps to the line's first character — @@ -391,7 +516,7 @@ internal fun wordTappedAt( text: String, position: Offset, fontSizePx: Float, -): String? { +): IntRange? { if (text.isEmpty() || fontSizePx <= 0f) return null val offset = layout.getOffsetForPosition(position).coerceIn(0, text.length - 1) val baseline = layout.getLineBaseline(layout.getLineForOffset(offset)) @@ -400,7 +525,7 @@ internal fun wordTappedAt( // swapped and taps start feeling off. if (position.y < baseline - fontSizePx * 1.4f) return null if (position.y > baseline + fontSizePx * 0.6f) return null - return wordAt(text, offset) + return wordRangeAt(text, offset) } /** Save this passage, or copy it. Saving keeps the link back to the poem; copying doesn't. */ diff --git a/app/src/main/java/com/ganjoor/android/ui/PoetsScreen.kt b/app/src/main/java/com/ganjoor/android/ui/PoetsScreen.kt index 7e456f0..85fec4e 100644 --- a/app/src/main/java/com/ganjoor/android/ui/PoetsScreen.kt +++ b/app/src/main/java/com/ganjoor/android/ui/PoetsScreen.kt @@ -99,7 +99,16 @@ fun PoetsScreen( ) }, ) { insets -> - Load(key = Unit, block = { Ganjoor.manifest() }) { manifest -> + Load( + key = Unit, + block = { Ganjoor.manifest() }, + // Cards or rows, whichever the reader chose, while the poets arrive. + placeholder = { + val top = Modifier.padding(top = insets.calculateTopPadding()) + if (LocalSettings.current.value.poetGrid) SkeletonCards(top, cards = 8, circle = true) + else SkeletonList(top, twoLines = false, leadingCircle = 44.dp) + }, + ) { manifest -> val settings = LocalSettings.current val sort = settings.value.poetSort val pinned = LocalPinnedPoets.current @@ -109,20 +118,7 @@ fun PoetsScreen( else manifest.poets.filter { it.nickname.contains(query.trim(), ignoreCase = true) } - when (sort) { - // Persian letters don't sort correctly by code point (آ vs ا, ی vs ي), so hand - // the ordering to a collator rather than String.compareTo. - PoetSort.Name -> { - val collator = Collator.getInstance(Locale.forLanguageTag("fa")) - matches.sortedWith { a, b -> collator.compare(a.nickname, b.nickname) } - } - - // Pinned first, in the order they were pinned, then the rest untouched. With - // nothing pinned this is Ganjoor's order, which is why it can be the default. - PoetSort.Pinned -> pinnedFirst(matches, pinned.items) - - PoetSort.Default -> matches - } + orderPoets(matches, sort, pinned.items) } val padding = PaddingValues( @@ -377,6 +373,23 @@ private fun PoetCard(poet: PoetRef, pinned: Boolean, onClick: () -> Unit, onPin: } } +/** The poets in the order [sort] asks for. Shared by the home screen and the large-screen column. */ +internal fun orderPoets(poets: List, sort: PoetSort, pins: List): List = + when (sort) { + // Persian letters don't sort correctly by code point (آ vs ا, ی vs ي), so hand + // the ordering to a collator rather than String.compareTo. + PoetSort.Name -> { + val collator = Collator.getInstance(Locale.forLanguageTag("fa")) + poets.sortedWith { a, b -> collator.compare(a.nickname, b.nickname) } + } + + // Pinned first, in the order they were pinned, then the rest untouched. With + // nothing pinned this is Ganjoor's order, which is why it can be the default. + PoetSort.Pinned -> pinnedFirst(poets, pins) + + PoetSort.Default -> poets + } + /** * Pinned poets first, in the order they were pinned, then everyone else as Ganjoor has them. * diff --git a/app/src/main/java/com/ganjoor/android/ui/Settings.kt b/app/src/main/java/com/ganjoor/android/ui/Settings.kt index 483e8f2..8a451d5 100644 --- a/app/src/main/java/com/ganjoor/android/ui/Settings.kt +++ b/app/src/main/java/com/ganjoor/android/ui/Settings.kt @@ -65,6 +65,8 @@ data class Prefs( val oled: Boolean = false, val poetSort: PoetSort = PoetSort.Pinned, val poetGrid: Boolean = true, + /** On a large screen, whether the poet and book columns are folded away to leave the poem. */ + val columnsHidden: Boolean = false, ) /** Reading preferences, kept in SharedPreferences and read once at startup. */ @@ -84,6 +86,7 @@ class Settings(context: Context) { oled = prefs.getBoolean("oled", prefs.getString("theme", null) == "Black"), poetSort = enumOrDefault(prefs.getString("poetSort", null), PoetSort.Pinned), poetGrid = prefs.getBoolean("poetGrid", true), + columnsHidden = prefs.getBoolean("columnsHidden", false), ) ) private set @@ -103,6 +106,7 @@ class Settings(context: Context) { putBoolean("oled", p.oled) putString("poetSort", p.poetSort.name) putBoolean("poetGrid", p.poetGrid) + putBoolean("columnsHidden", p.columnsHidden) } } } diff --git a/app/src/main/java/com/ganjoor/android/ui/Skeleton.kt b/app/src/main/java/com/ganjoor/android/ui/Skeleton.kt new file mode 100644 index 0000000..c5e5b9f --- /dev/null +++ b/app/src/main/java/com/ganjoor/android/ui/Skeleton.kt @@ -0,0 +1,147 @@ +package com.ganjoor.android.ui + +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +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.aspectRatio +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.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/* + * Placeholders shaped like what is about to arrive, so a part of the screen that is still loading + * looks like that part and nothing else does. Everything already on screen stays put around it. + */ + +/** Widths for placeholder lines, varied so a list of them reads as text rather than as stripes. */ +private val WIDTHS = listOf(0.82f, 0.64f, 0.9f, 0.7f, 0.76f, 0.58f, 0.86f, 0.68f) + +/** One slow pulse shared by every shape in a placeholder, so they breathe together. */ +@Composable +private fun pulse(): Float { + val alpha by rememberInfiniteTransition(label = "skeleton").animateFloat( + initialValue = 0.45f, + targetValue = 1f, + animationSpec = infiniteRepeatable(tween(900), RepeatMode.Reverse), + label = "skeleton alpha", + ) + return alpha +} + +@Composable +private fun Bone(modifier: Modifier, alpha: Float, shape: Shape = RoundedCornerShape(6.dp)) { + Box( + modifier + .graphicsLayer { this.alpha = alpha } + .clip(shape) + .background(MaterialTheme.colorScheme.surfaceContainerHighest) + ) +} + +/** Rows of a list: a title line and, with [twoLines], a shorter line under it. */ +@Composable +fun SkeletonList( + modifier: Modifier = Modifier, + rows: Int = 8, + twoLines: Boolean = true, + leadingCircle: Dp? = null, +) { + val alpha = pulse() + Column(modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(20.dp)) { + repeat(rows) { i -> + Row(verticalAlignment = Alignment.CenterVertically) { + if (leadingCircle != null) { + Bone(Modifier.size(leadingCircle), alpha, CircleShape) + Box(Modifier.size(16.dp)) + } + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Bone(Modifier.fillMaxWidth(WIDTHS[i % WIDTHS.size]).height(14.dp), alpha) + if (twoLines) { + Bone(Modifier.fillMaxWidth(WIDTHS[(i + 3) % WIDTHS.size] * 0.7f).height(10.dp), alpha) + } + } + } + } + } +} + +/** A poem on its way: a breadcrumb line, then couplets, the first half to the start, the second to the end. */ +@Composable +fun SkeletonPoem(modifier: Modifier = Modifier, couplets: Int = 6) { + val alpha = pulse() + Column(modifier, verticalArrangement = Arrangement.spacedBy(14.dp)) { + Bone(Modifier.fillMaxWidth(0.5f).height(14.dp), alpha) + Box(Modifier.height(8.dp)) + repeat(couplets) { i -> + Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { + Bone(Modifier.fillMaxWidth(WIDTHS[i % WIDTHS.size]).height(18.dp), alpha) + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) { + Bone(Modifier.fillMaxWidth(WIDTHS[(i + 2) % WIDTHS.size]).height(18.dp), alpha) + } + } + } + } +} + +/** Cards on their way, for the poet grid ([circle] portraits) or a book's contents. */ +@Composable +fun SkeletonCards(modifier: Modifier = Modifier, cards: Int = 6, circle: Boolean = false) { + val alpha = pulse() + Column(modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + repeat((cards + 1) / 2) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + repeat(2) { + Column( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer) + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (circle) Bone(Modifier.size(84.dp), alpha, CircleShape) + Bone(Modifier.fillMaxWidth(0.6f).height(14.dp), alpha) + } + } + } + } + } +} + +/** The poets column on its way: portrait discs over short names. */ +@Composable +fun SkeletonRail(modifier: Modifier = Modifier, disc: Dp = 56.dp) { + val alpha = pulse() + Column( + modifier.fillMaxSize().padding(vertical = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + repeat(8) { + Bone(Modifier.size(disc), alpha, CircleShape) + Bone(Modifier.fillMaxWidth(0.5f).aspectRatio(5f), alpha) + } + } +} diff --git a/app/src/main/java/com/ganjoor/android/ui/WordSheet.kt b/app/src/main/java/com/ganjoor/android/ui/WordSheet.kt index 350499b..db88f04 100644 --- a/app/src/main/java/com/ganjoor/android/ui/WordSheet.kt +++ b/app/src/main/java/com/ganjoor/android/ui/WordSheet.kt @@ -4,6 +4,10 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding @@ -13,10 +17,16 @@ import androidx.compose.foundation.layout.FlowRow import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Surface import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.SuggestionChip import androidx.compose.material3.Text +import androidx.activity.compose.BackHandler +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue @@ -78,27 +88,67 @@ fun WordSheet(word: String, onDismiss: () -> Unit, passage: Bookmark? = null) { ModalBottomSheet(onDismissRequest = onDismiss) { // Inside the lookup's own scroll, not after it: the lookup scrolls, so anything placed // below it is pushed past the bottom of the sheet with no way to reach it. - WordLookup(word) { - if (passage == null) return@WordLookup - HorizontalDivider(modifier = Modifier.padding(top = 8.dp)) - Text( - text = stringResource(R.string.this_couplet), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - ) - PassageActions(passage) - if (LocalAssistant.current.serverReady) { - AssistantInline( - prompt = "explain", - text = passage.excerpt.orEmpty(), - label = R.string.assistant_explain, - instruction = R.string.assistant_ask_prompt, + WordLookup(word) { CoupletFooter(passage) } + } +} + +/** + * The same lookup as [WordSheet], as a panel beside the poem on a large screen. It sits on the + * left, away from where the lines start, and the poem moves over for it rather than being + * covered; the cross, or Back, closes it. + */ +@Composable +fun WordPanel(word: String, onDismiss: () -> Unit, passage: Bookmark? = null) { + BackHandler(onBack = onDismiss) + Surface( + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.width(360.dp).fillMaxHeight(), + ) { + Column(Modifier.statusBarsPadding()) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 56.dp) + .padding(start = 20.dp, end = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.dictionary), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f), ) + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, stringResource(R.string.close)) + } } + HorizontalDivider(modifier = Modifier.padding(bottom = 12.dp)) + WordLookup(word) { CoupletFooter(passage) } } } } +/** The couplet's own actions, under the definitions, when the word came from a couplet. */ +@Composable +private fun CoupletFooter(passage: Bookmark?) { + if (passage == null) return + HorizontalDivider(modifier = Modifier.padding(top = 8.dp)) + Text( + text = stringResource(R.string.this_couplet), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + PassageActions(passage) + if (LocalAssistant.current.serverReady) { + AssistantInline( + prompt = "explain", + text = passage.excerpt.orEmpty(), + label = R.string.assistant_explain, + instruction = R.string.assistant_ask_prompt, + ) + } +} + /** * The lookup itself. Split out from the sheet because the selection-menu activity shows the same * thing as a plain screen: it launches in its own task, so there is nothing behind it for a sheet diff --git a/app/src/main/res/drawable/ic_menu_open.xml b/app/src/main/res/drawable/ic_menu_open.xml new file mode 100644 index 0000000..254faac --- /dev/null +++ b/app/src/main/res/drawable/ic_menu_open.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 163b848..9dcc99b 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -115,4 +115,8 @@ نمایش کارتی نمایش فهرستی پیش‌تر دریافت شده + + پنهان کردن فهرست + نمایش فهرست + بستن diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml index 32adcd6..20a71ff 100644 --- a/app/src/main/res/values-ur/strings.xml +++ b/app/src/main/res/values-ur/strings.xml @@ -115,4 +115,8 @@ کارڈ کی صورت فہرست کی صورت پہلے سے محفوظ ہے + + فہرست چھپائیں + فہرست دکھائیں + بند کریں diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 93ca018..51ad028 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -120,4 +120,8 @@ Show as cards Show as a list Already downloaded + + Hide the list + Show the list + Close diff --git a/app/src/test/java/com/ganjoor/android/ColumnUrlsTest.kt b/app/src/test/java/com/ganjoor/android/ColumnUrlsTest.kt new file mode 100644 index 0000000..e2c3586 --- /dev/null +++ b/app/src/test/java/com/ganjoor/android/ColumnUrlsTest.kt @@ -0,0 +1,34 @@ +package com.ganjoor.android + +import com.ganjoor.android.ui.columnUrls +import org.junit.Assert.assertEquals +import org.junit.Test + +class ColumnUrlsTest { + + /** A poem four levels down gets a column for each category above it, but none of its own. */ + @Test + fun `a poem lists every category above it`() { + assertEquals( + listOf("/saadi", "/saadi/golestan", "/saadi/golestan/bab1"), + columnUrls("/saadi/golestan/bab1/sh1", isPoem = true), + ) + } + + /** An open category is listed in its own column, with nothing in it chosen yet. */ + @Test + fun `a category gets its own column`() { + assertEquals(listOf("/hafez", "/hafez/ghazal"), columnUrls("/hafez/ghazal", isPoem = false)) + } + + @Test + fun `a poet is one column`() { + assertEquals(listOf("/hafez"), columnUrls("/hafez/", isPoem = false)) + } + + /** A poem straight under a poet has only the poet's column beside it. */ + @Test + fun `a poem at a poet's root`() { + assertEquals(listOf("/khayyam"), columnUrls("/khayyam/sh1", isPoem = true)) + } +}