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..c177aea 100644 --- a/app/src/main/java/com/ganjoor/android/ui/PoemScreen.kt +++ b/app/src/main/java/com/ganjoor/android/ui/PoemScreen.kt @@ -4,14 +4,19 @@ import androidx.activity.compose.BackHandler 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.FlowRow 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.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.text.selection.SelectionContainer @@ -20,10 +25,12 @@ import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.FavoriteBorder import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.VerticalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalContentColor @@ -38,6 +45,7 @@ import androidx.compose.runtime.getValue 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.geometry.Offset import androidx.compose.ui.input.pointer.pointerInput @@ -115,83 +123,188 @@ fun PoemScreen( ) } ) { insets -> - // Free-form selection for copying any span; tapping a word looks it up, and the - // per-couplet actions save a passage with the reference attached, which a raw copy - // would lose. - // - // The system selection menu gets its own entries from ProcessTextActivity, not from - // 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. - SelectionContainer { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues( - start = 20.dp, - end = 20.dp, - top = insets.calculateTopPadding() + 8.dp, - bottom = insets.calculateBottomPadding() + 32.dp, - ), - ) { - item { - Column(Modifier.padding(bottom = 12.dp)) { - Breadcrumbs(poem.fullTitle, poem.fullUrl.ifBlank { fullUrl }, onCategory) - RecitationPlayer(poem.id) - poem.metre?.rhythm?.let { rhythm -> - Text( - text = rhythm, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + // Narrow and wide are the same reader in different containers. On a phone the poem + // has the window to itself and the dictionary arrives as a sheet over it; given the + // width of a tablet or an opened foldable, the poem's place in the poet's work sits + // on one side of it and the dictionary on the other, and nothing covers the verse. + BoxWithConstraints(Modifier.fillMaxSize()) { + val wide = maxWidth >= WideScreen + + val body: @Composable (Modifier) -> Unit = { modifier -> + // Free-form selection for copying any span; tapping a word looks it up, and + // the per-couplet actions save a passage with the reference attached, which a + // raw copy would lose. + // + // The system selection menu gets its own entries from ProcessTextActivity, + // not from 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. + SelectionContainer(modifier) { + PoemBody( + poem = poem, + fullUrl = fullUrl, + couplets = couplets, + style = style, + showSummaries = prefs.showSummaries, + siblings = siblings, + here = here, + insets = insets, + onPoem = onPoem, + onCategory = onCategory, + onWord = { word, passage -> tapped = word to passage }, + ) + } + } + + if (wide) { + Row(Modifier.fillMaxSize()) { + // First child, so in this right-to-left layout it lands on the right. + PoemTree( + poemUrl = fullUrl, + onPoem = onPoem, + contentPadding = insets, + modifier = Modifier.width(300.dp).fillMaxHeight(), + ) + VerticalDivider() + body(Modifier.weight(1f)) + tapped?.let { (word, passage) -> + VerticalDivider() + DictionaryPane( + word = word, + passage = passage, + onClose = { tapped = null }, + insets = insets, + modifier = Modifier.width(360.dp).fillMaxHeight(), ) } } - } - - items(couplets) { couplet -> - Couplet( - couplet = couplet, - style = style, - showSummaries = prefs.showSummaries, - source = Bookmark(fullUrl, poem.title, poem.fullTitle), - onWord = { word, passage -> tapped = word to passage }, - ) - } - - if (prefs.showSummaries) { - poem.poemSummary?.takeIf { it.isNotBlank() }?.let { summary -> - item { PoemSummary(summary) } - } - } - - item { - SiblingNav( - previous = if (here > 0) siblings[here - 1] else null, - next = if (here >= 0) siblings.getOrNull(here + 1) else null, - onPoem = onPoem, - ) - } - - poem.sourceName?.takeIf { it.isNotBlank() }?.let { source -> - item { - Text( - text = stringResource(R.string.source, source), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 16.dp), + } else { + body(Modifier) + tapped?.let { (word, passage) -> + WordSheet( + word = word, + passage = passage, + onDismiss = { tapped = null }, ) } } } + } + } +} + +/** + * The poem itself, held to a reading measure. Left to fill a tablet, a couplet's two hemistichs + * are flung to opposite edges of the window with a hand's width of nothing between them, and the + * eye has to cross the whole screen between halves of one line. + */ +@Composable +private fun PoemBody( + poem: Poem, + fullUrl: String, + couplets: List>, + style: androidx.compose.ui.text.TextStyle, + showSummaries: Boolean, + siblings: List, + here: Int, + insets: PaddingValues, + onPoem: (String) -> Unit, + onCategory: (String) -> Unit, + onWord: (String, Bookmark) -> Unit, +) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { + LazyColumn( + modifier = Modifier.widthIn(max = ReadingMeasure).fillMaxSize(), + contentPadding = PaddingValues( + start = 20.dp, + end = 20.dp, + top = insets.calculateTopPadding() + 8.dp, + bottom = insets.calculateBottomPadding() + 32.dp, + ), + ) { + item { + Column(Modifier.padding(bottom = 12.dp)) { + Breadcrumbs(poem.fullTitle, poem.fullUrl.ifBlank { fullUrl }, onCategory) + RecitationPlayer(poem.id) + poem.metre?.rhythm?.let { rhythm -> + Text( + text = rhythm, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + items(couplets) { couplet -> + Couplet( + couplet = couplet, + style = style, + showSummaries = showSummaries, + source = Bookmark(fullUrl, poem.title, poem.fullTitle), + onWord = onWord, + ) + } + + if (showSummaries) { + poem.poemSummary?.takeIf { it.isNotBlank() }?.let { summary -> + item { PoemSummary(summary) } + } + } + + item { + SiblingNav( + previous = if (here > 0) siblings[here - 1] else null, + next = if (here >= 0) siblings.getOrNull(here + 1) else null, + onPoem = onPoem, + ) + } + + poem.sourceName?.takeIf { it.isNotBlank() }?.let { source -> + item { + Text( + text = stringResource(R.string.source, source), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 16.dp), + ) + } } } } +} - tapped?.let { (word, passage) -> - WordSheet( - word = word, - passage = passage, - onDismiss = { tapped = null }, - ) +/** + * The dictionary as a column beside the poem rather than a sheet over it, so the line the word + * came from stays in view while its meaning is read. Closeable, because it holds its word until + * dismissed — unlike the sheet, which a tap outside takes away. + */ +@Composable +private fun DictionaryPane( + word: String, + passage: Bookmark, + onClose: () -> Unit, + insets: PaddingValues, + modifier: Modifier = Modifier, +) { + Column(modifier.padding(top = insets.calculateTopPadding())) { + Row( + modifier = Modifier.fillMaxWidth().padding(start = 20.dp, end = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.dictionary), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = onClose) { + Icon(Icons.Default.Close, stringResource(R.string.close)) + } + } + HorizontalDivider() + WordLookup(word) { PassageFooter(passage) } } } diff --git a/app/src/main/java/com/ganjoor/android/ui/PoemTree.kt b/app/src/main/java/com/ganjoor/android/ui/PoemTree.kt new file mode 100644 index 0000000..1457267 --- /dev/null +++ b/app/src/main/java/com/ganjoor/android/ui/PoemTree.kt @@ -0,0 +1,226 @@ +package com.ganjoor.android.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +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.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.ganjoor.android.R +import com.ganjoor.android.data.Category +import com.ganjoor.android.data.Ganjoor + +/** + * Below this, the window is a phone: one column, and the dictionary arrives as a sheet over the + * poem. At or above it there is room to put the poem's place in the poet's work beside it, and + * the dictionary beside that, without squeezing the measure the poetry is set to. + * + * 840dp is Material's "expanded" width — a tablet in landscape, or a foldable opened out. + */ +val WideScreen = 840.dp + +/** The reading measure. A line of verse set across a whole tablet is a line nobody can follow. */ +val ReadingMeasure = 680.dp + +/** + * One row of the contents tree. [expanded] is null for a poem, which has nothing to open; + * [url] is null for the placeholder shown while a branch is still loading. + */ +private data class TreeRow( + val label: String, + val url: String?, + val depth: Int, + val expanded: Boolean?, + val current: Boolean = false, +) + +/** Every category above [poemUrl]: `/hafez/ghazal/sh1` -> `/hafez`, `/hafez/ghazal`. */ +private fun ancestorUrls(poemUrl: String): List { + val segments = poemUrl.trim('/').split('/').filter { it.isNotEmpty() } + if (segments.size <= 1) return emptyList() + return (1 until segments.size).map { "/" + segments.take(it).joinToString("/") } +} + +/** + * Flattens the loaded part of the tree into rows. A branch that is open but not yet fetched + * contributes a spinner rather than nothing, so an expanding node never looks broken. + */ +private fun buildRows( + root: String, + loaded: Map, + expanded: Set, + current: String, +): List { + val rows = mutableListOf() + + fun walk(url: String, depth: Int) { + val category = loaded[url] + if (category == null) { + rows += TreeRow(label = "", url = null, depth = depth, expanded = null) + return + } + category.childCats.forEach { child -> + val open = child.fullUrl in expanded + rows += TreeRow(child.title, child.fullUrl, depth, open) + if (open) walk(child.fullUrl, depth + 1) + } + category.poems.forEach { poem -> + rows += TreeRow(poem.title, poem.fullUrl, depth, null, poem.fullUrl == current) + } + } + + walk(root, 0) + return rows +} + +/** + * Where this poem sits in its poet's work — poet » book » section » poem — as a tree beside the + * reader, with the path to the open poem already unfolded and scrolled to. + * + * Tapping a category folds it open or shut rather than navigating: a section's own page lists the + * same poems this tree already shows, so opening it would only cost the reader their place. The + * breadcrumbs above the poem are the way out to a category page. + * + * ponytail: branches are fetched as they open and kept only while the screen lives. The disk + * cache makes re-opening cheap, and a poet's whole tree is far too much to hold eagerly. + */ +@Composable +fun PoemTree( + poemUrl: String, + onPoem: (String) -> Unit, + contentPadding: PaddingValues, + modifier: Modifier = Modifier, +) { + val ancestors = remember(poemUrl) { ancestorUrls(poemUrl) } + if (ancestors.isEmpty()) return + val root = ancestors.first() + + val loaded = remember(poemUrl) { mutableStateMapOf() } + var expanded by remember(poemUrl) { mutableStateOf(ancestors.toSet()) } + var unreachable by remember(poemUrl) { mutableStateOf(emptySet()) } + + LaunchedEffect(poemUrl, expanded) { + expanded.filterNot { it in loaded || it in unreachable }.forEach { url -> + runCatching { Ganjoor.category(url) } + .onSuccess { loaded[url] = it } + // A branch that will not load stays shut rather than retrying on every + // recomposition; the poem itself is already on screen and still readable. + .onFailure { unreachable = unreachable + url } + } + } + + val rows = remember(loaded.size, expanded, poemUrl) { + buildRows(root, loaded, expanded, poemUrl) + } + + val listState = rememberLazyListState() + var settled by remember(poemUrl) { mutableStateOf(false) } + // Once, when the open poem's row first exists: a book of 495 ghazals opens far from the one + // being read, and a tree that starts at ghazal 1 is a tree nobody scrolls. + LaunchedEffect(rows) { + if (settled) return@LaunchedEffect + val index = rows.indexOfFirst { it.current } + if (index >= 0) { + listState.scrollToItem(index + 1) + settled = true + } + } + + LazyColumn(modifier = modifier, state = listState, contentPadding = contentPadding) { + item { + Text( + text = loaded[root]?.title ?: stringResource(R.string.contents), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 12.dp, bottom = 8.dp), + ) + } + items(rows) { row -> + TreeRowItem( + row = row, + onToggle = { + expanded = if (row.url in expanded) expanded - row.url!! else expanded + row.url!! + }, + onPoem = onPoem, + ) + } + } +} + +@Composable +private fun TreeRowItem(row: TreeRow, onToggle: () -> Unit, onPoem: (String) -> Unit) { + // Indent by depth, on top of the row's own padding, so nesting reads at a glance. + val indent = (row.depth * 14).dp + + if (row.url == null) { + Box( + modifier = Modifier.fillMaxWidth().padding(start = indent + 12.dp, top = 8.dp, bottom = 8.dp), + ) { + CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp) + } + return + } + + val isCategory = row.expanded != null + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { if (isCategory) onToggle() else onPoem(row.url) } + .padding(start = indent + 12.dp, end = 12.dp, top = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + if (isCategory) { + Icon( + // Down when open, and pointing along the text when shut — the chevron is + // auto-mirrored, so it turns the right way in a right-to-left layout. + imageVector = if (row.expanded == true) Icons.Default.KeyboardArrowDown + else Icons.AutoMirrored.Filled.KeyboardArrowLeft, + contentDescription = stringResource( + if (row.expanded == true) R.string.collapse else R.string.expand + ), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + } + Column(Modifier.fillMaxWidth()) { + Text( + text = row.label, + style = if (isCategory) MaterialTheme.typography.bodyMedium + else MaterialTheme.typography.bodySmall, + fontWeight = if (row.current) FontWeight.Bold else null, + color = when { + row.current -> MaterialTheme.colorScheme.primary + isCategory -> MaterialTheme.colorScheme.onSurface + else -> MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } +} 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..2395779 100644 --- a/app/src/main/java/com/ganjoor/android/ui/WordSheet.kt +++ b/app/src/main/java/com/ganjoor/android/ui/WordSheet.kt @@ -78,24 +78,33 @@ 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) { PassageFooter(passage) } + } +} + +/** + * The couplet the word was tapped in, with its own actions, under the definitions. + * + * Shared by the phone's sheet and the wide-screen dictionary pane, which show the same lookup in + * two different containers. + */ +@Composable +internal fun ColumnScope.PassageFooter(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, + ) } } diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 163b848..ee61da0 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..a7a72c5 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..b460be7 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 + Close + Contents + Expand + Collapse