Give the reader a wide-screen shape: measure, contents, dictionary beside it

Three changes, all behind one breakpoint at 840dp — Material's "expanded"
width, which is a tablet in landscape or a foldable opened out. Below it
nothing moves.

The measure. A couplet's two hemistichs are set to the start and end of the
line, which on a phone reads as one unit and on a tablet flings them to
opposite edges with a hand's width of nothing between. The poem is now held to
680dp and centred, so the halves of a line stay within one glance of each
other. On a phone the constraint never binds.

Contents. PoemTree puts the poem's place in the poet's work beside it — poet »
book » section » poem — with the path to the open poem already unfolded and
scrolled to, because a book of 495 ghazals otherwise opens at ghazal 1.
Branches load as they open. Tapping a category folds it rather than navigating:
the section's own page lists the same poems, so opening it would only cost the
reader their place.

The dictionary. On a wide screen it is 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, and it holds its word until closed. The sheet and the pane share one
lookup and one footer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
anas 2026-10-07 16:10:21 +02:00
parent 50bbe0b729
commit e284872eaa
6 changed files with 444 additions and 84 deletions

View File

@ -4,14 +4,19 @@ import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement 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.FlowRow
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size 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.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.selection.SelectionContainer 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.KeyboardArrowUp
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.VerticalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor
@ -38,6 +45,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
@ -115,83 +123,188 @@ fun PoemScreen(
) )
} }
) { insets -> ) { insets ->
// Free-form selection for copying any span; tapping a word looks it up, and the // Narrow and wide are the same reader in different containers. On a phone the poem
// per-couplet actions save a passage with the reference attached, which a raw copy // has the window to itself and the dictionary arrives as a sheet over it; given the
// would lose. // 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.
// The system selection menu gets its own entries from ProcessTextActivity, not from BoxWithConstraints(Modifier.fillMaxSize()) {
// here: Compose 1.10 stopped routing SelectionContainer through LocalTextToolbar, so val wide = maxWidth >= WideScreen
// 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. val body: @Composable (Modifier) -> Unit = { modifier ->
SelectionContainer { // Free-form selection for copying any span; tapping a word looks it up, and
LazyColumn( // the per-couplet actions save a passage with the reference attached, which a
modifier = Modifier.fillMaxSize(), // raw copy would lose.
contentPadding = PaddingValues( //
start = 20.dp, // The system selection menu gets its own entries from ProcessTextActivity,
end = 20.dp, // not from here: Compose 1.10 stopped routing SelectionContainer through
top = insets.calculateTopPadding() + 8.dp, // LocalTextToolbar, so a custom TextToolbar is never asked to show.
bottom = insets.calculateBottomPadding() + 32.dp, // ACTION_PROCESS_TEXT goes round that, and reaches every other app's
), // selection menu as a side effect.
) { SelectionContainer(modifier) {
item { PoemBody(
Column(Modifier.padding(bottom = 12.dp)) { poem = poem,
Breadcrumbs(poem.fullTitle, poem.fullUrl.ifBlank { fullUrl }, onCategory) fullUrl = fullUrl,
RecitationPlayer(poem.id) couplets = couplets,
poem.metre?.rhythm?.let { rhythm -> style = style,
Text( showSummaries = prefs.showSummaries,
text = rhythm, siblings = siblings,
style = MaterialTheme.typography.bodySmall, here = here,
color = MaterialTheme.colorScheme.onSurfaceVariant, 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(),
) )
} }
} }
} } else {
body(Modifier)
items(couplets) { couplet -> tapped?.let { (word, passage) ->
Couplet( WordSheet(
couplet = couplet, word = word,
style = style, passage = passage,
showSummaries = prefs.showSummaries, onDismiss = { tapped = null },
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),
) )
} }
} }
} }
}
}
}
/**
* 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<List<Verse>>,
style: androidx.compose.ui.text.TextStyle,
showSummaries: Boolean,
siblings: List<PoemRef>,
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( * The dictionary as a column beside the poem rather than a sheet over it, so the line the word
word = word, * came from stays in view while its meaning is read. Closeable, because it holds its word until
passage = passage, * dismissed — unlike the sheet, which a tap outside takes away.
onDismiss = { tapped = null }, */
) @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) }
} }
} }

View File

@ -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<String> {
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<String, Category>,
expanded: Set<String>,
current: String,
): List<TreeRow> {
val rows = mutableListOf<TreeRow>()
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<String, Category>() }
var expanded by remember(poemUrl) { mutableStateOf(ancestors.toSet()) }
var unreachable by remember(poemUrl) { mutableStateOf(emptySet<String>()) }
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
},
)
}
}
}

View File

@ -78,24 +78,33 @@ fun WordSheet(word: String, onDismiss: () -> Unit, passage: Bookmark? = null) {
ModalBottomSheet(onDismissRequest = onDismiss) { ModalBottomSheet(onDismissRequest = onDismiss) {
// Inside the lookup's own scroll, not after it: the lookup scrolls, so anything placed // 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. // below it is pushed past the bottom of the sheet with no way to reach it.
WordLookup(word) { WordLookup(word) { PassageFooter(passage) }
if (passage == null) return@WordLookup }
HorizontalDivider(modifier = Modifier.padding(top = 8.dp)) }
Text(
text = stringResource(R.string.this_couplet), /**
style = MaterialTheme.typography.labelMedium, * The couplet the word was tapped in, with its own actions, under the definitions.
color = MaterialTheme.colorScheme.primary, *
) * Shared by the phone's sheet and the wide-screen dictionary pane, which show the same lookup in
PassageActions(passage) * two different containers.
if (LocalAssistant.current.serverReady) { */
AssistantInline( @Composable
prompt = "explain", internal fun ColumnScope.PassageFooter(passage: Bookmark?) {
text = passage.excerpt.orEmpty(), if (passage == null) return
label = R.string.assistant_explain, HorizontalDivider(modifier = Modifier.padding(top = 8.dp))
instruction = R.string.assistant_ask_prompt, 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,
)
} }
} }

View File

@ -115,4 +115,8 @@
<string name="view_grid">نمایش کارتی</string> <string name="view_grid">نمایش کارتی</string>
<string name="view_list">نمایش فهرستی</string> <string name="view_list">نمایش فهرستی</string>
<string name="downloaded_poet">پیش‌تر دریافت شده</string> <string name="downloaded_poet">پیش‌تر دریافت شده</string>
<string name="close">بستن</string>
<string name="contents">فهرست</string>
<string name="expand">گشودن</string>
<string name="collapse">بستن شاخه</string>
</resources> </resources>

View File

@ -115,4 +115,8 @@
<string name="view_grid">کارڈ کی صورت</string> <string name="view_grid">کارڈ کی صورت</string>
<string name="view_list">فہرست کی صورت</string> <string name="view_list">فہرست کی صورت</string>
<string name="downloaded_poet">پہلے سے محفوظ ہے</string> <string name="downloaded_poet">پہلے سے محفوظ ہے</string>
<string name="close">بند کریں</string>
<string name="contents">فہرست</string>
<string name="expand">کھولیں</string>
<string name="collapse">بند کریں</string>
</resources> </resources>

View File

@ -120,4 +120,8 @@
<string name="view_grid">Show as cards</string> <string name="view_grid">Show as cards</string>
<string name="view_list">Show as a list</string> <string name="view_list">Show as a list</string>
<string name="downloaded_poet">Already downloaded</string> <string name="downloaded_poet">Already downloaded</string>
<string name="close">Close</string>
<string name="contents">Contents</string>
<string name="expand">Expand</string>
<string name="collapse">Collapse</string>
</resources> </resources>