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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RhmDdrN5hgWrgRcFLAMsV6
This commit is contained in:
Claude 2026-10-07 14:05:07 +00:00
parent 50bbe0b729
commit cf65072af4
No known key found for this signature in database
16 changed files with 1294 additions and 98 deletions

View File

@ -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 |

View File

@ -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

View File

@ -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<Int, String>()) }
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) }

View File

@ -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<Manifest?>(null)
val categories = mutableStateMapOf<String, Category>()
val excerpts = mutableStateMapOf<Int, Map<Int, String>>()
val railState = LazyListState()
private val listStates = HashMap<String, LazyListState>()
private val widths = HashMap<String, Animatable<Float, AnimationVector1D>>()
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<Float, AnimationVector1D> = 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<String> {
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,
)
}
}
}
}
}
}
}
}
}

View File

@ -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<PoetsRoute> {
PoetsScreen(
@ -103,34 +131,73 @@ fun GanjoorApp() {
}
composable<CategoryRoute> { entry ->
val url = entry.toRoute<CategoryRoute>().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<PoemRoute> { entry ->
val route = entry.toRoute<PoemRoute>()
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<PoemRoute> { 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<PoemRoute> { 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<SearchRoute> { 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 })

View File

@ -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 <T> Load(key: Any?, block: suspend () -> T, content: @Composable (T) -> Unit) {
fun <T> 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<Result<T>?>(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 <T> 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(

View File

@ -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<Pair<String, Bookmark>?>(null) }
var tapped by remember { mutableStateOf<WordTap?>(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<TextLayoutResult?>(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. */

View File

@ -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<PoetRef>, sort: PoetSort, pins: List<String>): List<PoetRef> =
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.
*

View File

@ -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)
}
}
}

View File

@ -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)
}
}
}

View File

@ -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

View File

@ -0,0 +1,12 @@
<!-- Material "menu_open", for folding the large-screen columns away. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?android:attr/textColorPrimary">
<path
android:fillColor="@android:color/white"
android:pathData="M3,18h13v-2H3v2zM3,13h10v-2H3v2zM3,6v2h13V6H3zM21,15.59L17.42,12 21,8.41 19.59,7l-5,5 5,5L21,15.59z" />
</vector>

View File

@ -115,4 +115,8 @@
<string name="view_grid">نمایش کارتی</string>
<string name="view_list">نمایش فهرستی</string>
<string name="downloaded_poet">پیش‌تر دریافت شده</string>
<!-- Large screens: folds the poet and book columns away to leave the poem. -->
<string name="hide_columns">پنهان کردن فهرست</string>
<string name="show_columns">نمایش فهرست</string>
<string name="close">بستن</string>
</resources>

View File

@ -115,4 +115,8 @@
<string name="view_grid">کارڈ کی صورت</string>
<string name="view_list">فہرست کی صورت</string>
<string name="downloaded_poet">پہلے سے محفوظ ہے</string>
<!-- Large screens: folds the poet and book columns away to leave the poem. -->
<string name="hide_columns">فہرست چھپائیں</string>
<string name="show_columns">فہرست دکھائیں</string>
<string name="close">بند کریں</string>
</resources>

View File

@ -120,4 +120,8 @@
<string name="view_grid">Show as cards</string>
<string name="view_list">Show as a list</string>
<string name="downloaded_poet">Already downloaded</string>
<!-- Large screens: folds the poet and book columns away to leave the poem. -->
<string name="hide_columns">Hide the list</string>
<string name="show_columns">Show the list</string>
<string name="close">Close</string>
</resources>

View File

@ -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))
}
}