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:
parent
50bbe0b729
commit
cf65072af4
@ -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.
|
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
|
## Fonts
|
||||||
|
|
||||||
| Font | Used for | Licence |
|
| Font | Used for | Licence |
|
||||||
|
|||||||
@ -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. */
|
/** 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
|
if (text.isEmpty()) return null
|
||||||
val at = index.coerceIn(0, text.length - 1)
|
val at = index.coerceIn(0, text.length - 1)
|
||||||
if (!text[at].isWordChar()) return null
|
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--
|
while (start > 0 && text[start - 1].isWordChar()) start--
|
||||||
var end = at
|
var end = at
|
||||||
while (end < text.length - 1 && text[end + 1].isWordChar()) end++
|
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
|
private fun Char.isWordChar() = this in ''..'ۿ' || this == ZWNJ
|
||||||
|
|||||||
@ -56,7 +56,12 @@ fun CategoryScreen(
|
|||||||
// System Back climbs the tree too, not the visit history.
|
// System Back climbs the tree too, not the visit history.
|
||||||
BackHandler(onBack = onUp)
|
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.
|
// 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>()) }
|
var excerpts by remember(cat.id) { mutableStateOf(emptyMap<Int, String>()) }
|
||||||
LaunchedEffect(cat.id) {
|
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. */
|
/** 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('/') }
|
fullUrl.trim('/').takeIf { it.isNotEmpty() && !it.contains('/') }
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PoetDownloadAction(slug: String) {
|
internal fun PoetDownloadAction(slug: String) {
|
||||||
val progress = Downloads.running[slug]
|
val progress = Downloads.running[slug]
|
||||||
val saved = remember(slug, Downloads.revision) { Offline.isSaved(slug) }
|
val saved = remember(slug, Downloads.revision) { Offline.isSaved(slug) }
|
||||||
|
|
||||||
|
|||||||
669
app/src/main/java/com/ganjoor/android/ui/ColumnBrowser.kt
Normal file
669
app/src/main/java/com/ganjoor/android/ui/ColumnBrowser.kt
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,12 +1,20 @@
|
|||||||
package com.ganjoor.android.ui
|
package com.ganjoor.android.ui
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection
|
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.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
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.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
@ -71,6 +79,8 @@ private fun NavController.goUp(fromUrl: String) {
|
|||||||
fun GanjoorApp() {
|
fun GanjoorApp() {
|
||||||
val nav = rememberNavController()
|
val nav = rememberNavController()
|
||||||
var settingsOpen by rememberSaveable { mutableStateOf(false) }
|
var settingsOpen by rememberSaveable { mutableStateOf(false) }
|
||||||
|
// Above the NavHost, so the large-screen columns survive moving between destinations.
|
||||||
|
val browser = remember { BrowserState() }
|
||||||
|
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalOpenReadingSettings provides { settingsOpen = true },
|
LocalOpenReadingSettings provides { settingsOpen = true },
|
||||||
@ -83,15 +93,33 @@ fun GanjoorApp() {
|
|||||||
nav.navigate(AssistantRoute)
|
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(
|
NavHost(
|
||||||
navController = nav,
|
navController = nav,
|
||||||
startDestination = PoetsRoute,
|
startDestination = PoetsRoute,
|
||||||
// Start/End rather than Left/Right, so going deeper always moves against the reading
|
// 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.
|
// direction — leftwards here, since the app lays out right-to-left. In columns the
|
||||||
enterTransition = { slideIntoContainer(SlideDirection.Start) },
|
// page changes in place: sliding the whole screen would drag the columns with it.
|
||||||
exitTransition = { slideOutOfContainer(SlideDirection.Start) },
|
enterTransition = {
|
||||||
popEnterTransition = { slideIntoContainer(SlideDirection.End) },
|
if (wide) EnterTransition.None else slideIntoContainer(SlideDirection.Start)
|
||||||
popExitTransition = { slideOutOfContainer(SlideDirection.End) },
|
},
|
||||||
|
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> {
|
composable<PoetsRoute> {
|
||||||
PoetsScreen(
|
PoetsScreen(
|
||||||
@ -103,6 +131,27 @@ fun GanjoorApp() {
|
|||||||
}
|
}
|
||||||
composable<CategoryRoute> { entry ->
|
composable<CategoryRoute> { entry ->
|
||||||
val url = entry.toRoute<CategoryRoute>().url
|
val url = entry.toRoute<CategoryRoute>().url
|
||||||
|
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(
|
CategoryScreen(
|
||||||
fullUrl = url,
|
fullUrl = url,
|
||||||
onUp = { nav.goUp(url) },
|
onUp = { nav.goUp(url) },
|
||||||
@ -111,8 +160,10 @@ fun GanjoorApp() {
|
|||||||
onPoem = { nav.open(PoemRoute(it)) },
|
onPoem = { nav.open(PoemRoute(it)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
composable<PoemRoute> { entry ->
|
composable<PoemRoute> { entry ->
|
||||||
val route = entry.toRoute<PoemRoute>()
|
val route = entry.toRoute<PoemRoute>()
|
||||||
|
val poem: @Composable (Boolean, (@Composable () -> Unit)?) -> Unit = { wideText, toggle ->
|
||||||
PoemScreen(
|
PoemScreen(
|
||||||
fullUrl = route.url,
|
fullUrl = route.url,
|
||||||
// Reading on through a divan keeps the origin, so Back still lands where
|
// Reading on through a divan keeps the origin, so Back still lands where
|
||||||
@ -130,8 +181,24 @@ fun GanjoorApp() {
|
|||||||
},
|
},
|
||||||
// Tapping a breadcrumb leaves the saved list behind and starts browsing.
|
// Tapping a breadcrumb leaves the saved list behind and starts browsing.
|
||||||
onCategory = { nav.open(CategoryRoute(it)) },
|
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 ->
|
composable<SearchRoute> { entry ->
|
||||||
SearchScreen(
|
SearchScreen(
|
||||||
initialTerm = entry.toRoute<SearchRoute>().term,
|
initialTerm = entry.toRoute<SearchRoute>().term,
|
||||||
@ -158,6 +225,7 @@ fun GanjoorApp() {
|
|||||||
AboutScreen(onUp = { nav.navigateUp() }, onHome = { nav.goHome() })
|
AboutScreen(onUp = { nav.navigateUp() }, onHome = { nav.goHome() })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Inside the provider: the sheet reads LocalOpenAbout, so it has to be in scope.
|
// Inside the provider: the sheet reads LocalOpenAbout, so it has to be in scope.
|
||||||
if (settingsOpen) ReadingSettingsSheet(onDismiss = { settingsOpen = false })
|
if (settingsOpen) ReadingSettingsSheet(onDismiss = { settingsOpen = false })
|
||||||
|
|||||||
@ -1,12 +1,17 @@
|
|||||||
package com.ganjoor.android.ui
|
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.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
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.
|
* 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.
|
* 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.
|
* Promote to a ViewModel when a screen gains state worth surviving rotation.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@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) }
|
var attempt by remember(key) { mutableIntStateOf(0) }
|
||||||
val result by produceState<Result<T>?>(null, key, attempt) { value = runCatching { block() } }
|
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 {
|
when {
|
||||||
// Painted explicitly: on the category and poem screens Load sits outside the
|
// 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
|
// 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
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(MaterialTheme.colorScheme.background),
|
.background(MaterialTheme.colorScheme.background),
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
) {
|
||||||
CircularProgressIndicator()
|
placeholder()
|
||||||
}
|
}
|
||||||
|
|
||||||
outcome.isFailure -> Column(
|
outcome.isFailure -> Column(
|
||||||
|
|||||||
@ -1,9 +1,18 @@
|
|||||||
package com.ganjoor.android.ui
|
package com.ganjoor.android.ui
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
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.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.Spacer
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
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
|
||||||
@ -47,13 +56,15 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
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.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.ganjoor.android.R
|
import com.ganjoor.android.R
|
||||||
import com.ganjoor.android.data.Bookmark
|
import com.ganjoor.android.data.Bookmark
|
||||||
import com.ganjoor.android.data.breadcrumbs
|
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.Ganjoor
|
||||||
import com.ganjoor.android.data.LocalAssistant
|
import com.ganjoor.android.data.LocalAssistant
|
||||||
import com.ganjoor.android.data.LocalBookmarks
|
import com.ganjoor.android.data.LocalBookmarks
|
||||||
@ -71,14 +82,23 @@ fun PoemScreen(
|
|||||||
onHome: () -> Unit,
|
onHome: () -> Unit,
|
||||||
onPoem: (String) -> Unit,
|
onPoem: (String) -> Unit,
|
||||||
onCategory: (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)
|
BackHandler(onBack = onUp)
|
||||||
|
|
||||||
// The couplet travels with the word: the dictionary sheet is the one gesture every reader
|
// 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.
|
// 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 prefs = LocalSettings.current.value
|
||||||
val style = readingStyle(prefs.font, prefs.fontSize, prefs.fontWeight.weight)
|
val style = readingStyle(prefs.font, prefs.fontSize, prefs.fontWeight.weight)
|
||||||
val couplets = remember(poem) { poem.verses.couplets() }
|
val couplets = remember(poem) { poem.verses.couplets() }
|
||||||
@ -93,14 +113,21 @@ fun PoemScreen(
|
|||||||
}
|
}
|
||||||
val here = siblings.indexOfFirst { it.fullUrl == fullUrl }
|
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(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(poem.title, maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
title = { Text(poem.title, maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
|
Row {
|
||||||
|
navigationToggle?.invoke()
|
||||||
IconButton(onClick = onUp) {
|
IconButton(onClick = onUp) {
|
||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back))
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
HomeAction(onHome)
|
HomeAction(onHome)
|
||||||
@ -123,12 +150,18 @@ fun PoemScreen(
|
|||||||
// here: Compose 1.10 stopped routing SelectionContainer through LocalTextToolbar, so
|
// here: Compose 1.10 stopped routing SelectionContainer through LocalTextToolbar, so
|
||||||
// a custom TextToolbar is never asked to show. ACTION_PROCESS_TEXT goes round that,
|
// 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.
|
// 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 {
|
SelectionContainer {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(
|
contentPadding = PaddingValues(
|
||||||
start = 20.dp,
|
start = side,
|
||||||
end = 20.dp,
|
end = side,
|
||||||
top = insets.calculateTopPadding() + 8.dp,
|
top = insets.calculateTopPadding() + 8.dp,
|
||||||
bottom = insets.calculateBottomPadding() + 32.dp,
|
bottom = insets.calculateBottomPadding() + 32.dp,
|
||||||
),
|
),
|
||||||
@ -151,9 +184,11 @@ fun PoemScreen(
|
|||||||
Couplet(
|
Couplet(
|
||||||
couplet = couplet,
|
couplet = couplet,
|
||||||
style = style,
|
style = style,
|
||||||
|
sideBySide = sideBySide,
|
||||||
showSummaries = prefs.showSummaries,
|
showSummaries = prefs.showSummaries,
|
||||||
source = Bookmark(fullUrl, poem.title, poem.fullTitle),
|
source = Bookmark(fullUrl, poem.title, poem.fullTitle),
|
||||||
onWord = { word, passage -> tapped = word to passage },
|
onWord = { tapped = it },
|
||||||
|
tapped = tapped,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -185,14 +220,63 @@ fun PoemScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
tapped?.let { (word, passage) ->
|
if (wide) {
|
||||||
WordSheet(
|
AnimatedVisibility(
|
||||||
word = word,
|
visible = tapped != null,
|
||||||
passage = passage,
|
enter = expandHorizontally() + fadeIn(),
|
||||||
|
exit = shrinkHorizontally() + fadeOut(),
|
||||||
|
) {
|
||||||
|
tapped?.let { tap ->
|
||||||
|
WordPanel(
|
||||||
|
word = tap.word,
|
||||||
|
passage = tap.passage,
|
||||||
onDismiss = { tapped = null },
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The poem's path, with every ancestor tappable: poet » book » section » this poem. */
|
/** The poem's path, with every ancestor tappable: poet » book » section » this poem. */
|
||||||
@ -271,22 +355,45 @@ private fun Couplet(
|
|||||||
style: androidx.compose.ui.text.TextStyle,
|
style: androidx.compose.ui.text.TextStyle,
|
||||||
showSummaries: Boolean,
|
showSummaries: Boolean,
|
||||||
source: Bookmark,
|
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.
|
// Tap, not long-press: long-press belongs to the text selection this sits inside.
|
||||||
var actionsOpen by remember(couplet) { mutableStateOf(false) }
|
var actionsOpen by remember(couplet) { mutableStateOf(false) }
|
||||||
val passage = source.copy(excerpt = couplet.joinToString("\n") { it.text })
|
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)) {
|
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||||
|
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 ->
|
couplet.forEach { verse ->
|
||||||
VerseText(
|
VerseText(
|
||||||
verse = verse,
|
verse = verse,
|
||||||
style = style,
|
style = style,
|
||||||
onWord = { onWord(it, passage) },
|
onWord = { range -> onWord(tapOf(verse, range, passage)) },
|
||||||
// A tap that lands between words still opens the couplet's own actions.
|
// A tap that lands between words still opens the couplet's own actions.
|
||||||
onElsewhere = { actionsOpen = !actionsOpen },
|
onElsewhere = { actionsOpen = !actionsOpen },
|
||||||
|
highlight = tapped?.takeIf { it.verse == verse.vOrder }?.range,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (showSummaries) {
|
if (showSummaries) {
|
||||||
couplet.firstNotNullOfOrNull { it.coupletSummary }
|
couplet.firstNotNullOfOrNull { it.coupletSummary }
|
||||||
?.takeIf { it.isNotBlank() }
|
?.takeIf { it.isNotBlank() }
|
||||||
@ -346,18 +453,36 @@ private fun Couplet(
|
|||||||
* One hemistich. Tapping a word looks it up; tapping between words falls through to the
|
* 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.
|
* 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
|
@Composable
|
||||||
private fun VerseText(
|
private fun VerseText(
|
||||||
verse: Verse,
|
verse: Verse,
|
||||||
style: androidx.compose.ui.text.TextStyle,
|
style: androidx.compose.ui.text.TextStyle,
|
||||||
onWord: (String) -> Unit,
|
onWord: (IntRange) -> Unit,
|
||||||
onElsewhere: () -> 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) }
|
var layout by remember(verse.text) { mutableStateOf<TextLayoutResult?>(null) }
|
||||||
val fontSizePx = with(LocalDensity.current) { style.fontSize.toPx() }
|
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(
|
||||||
text = verse.text,
|
text = text,
|
||||||
style = style,
|
style = style,
|
||||||
textAlign = when (verse.position) {
|
textAlign = when (verse.position) {
|
||||||
Verse.RIGHT -> TextAlign.Start
|
Verse.RIGHT -> TextAlign.Start
|
||||||
@ -367,7 +492,7 @@ private fun VerseText(
|
|||||||
else -> TextAlign.Justify
|
else -> TextAlign.Justify
|
||||||
},
|
},
|
||||||
onTextLayout = { layout = it },
|
onTextLayout = { layout = it },
|
||||||
modifier = Modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.pointerInput(verse.text) {
|
.pointerInput(verse.text) {
|
||||||
detectTapGestures { position ->
|
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
|
* 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 —
|
* 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,
|
text: String,
|
||||||
position: Offset,
|
position: Offset,
|
||||||
fontSizePx: Float,
|
fontSizePx: Float,
|
||||||
): String? {
|
): IntRange? {
|
||||||
if (text.isEmpty() || fontSizePx <= 0f) return null
|
if (text.isEmpty() || fontSizePx <= 0f) return null
|
||||||
val offset = layout.getOffsetForPosition(position).coerceIn(0, text.length - 1)
|
val offset = layout.getOffsetForPosition(position).coerceIn(0, text.length - 1)
|
||||||
val baseline = layout.getLineBaseline(layout.getLineForOffset(offset))
|
val baseline = layout.getLineBaseline(layout.getLineForOffset(offset))
|
||||||
@ -400,7 +525,7 @@ internal fun wordTappedAt(
|
|||||||
// swapped and taps start feeling off.
|
// swapped and taps start feeling off.
|
||||||
if (position.y < baseline - fontSizePx * 1.4f) return null
|
if (position.y < baseline - fontSizePx * 1.4f) return null
|
||||||
if (position.y > baseline + fontSizePx * 0.6f) 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. */
|
/** Save this passage, or copy it. Saving keeps the link back to the poem; copying doesn't. */
|
||||||
|
|||||||
@ -99,7 +99,16 @@ fun PoetsScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { insets ->
|
) { 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 settings = LocalSettings.current
|
||||||
val sort = settings.value.poetSort
|
val sort = settings.value.poetSort
|
||||||
val pinned = LocalPinnedPoets.current
|
val pinned = LocalPinnedPoets.current
|
||||||
@ -109,20 +118,7 @@ fun PoetsScreen(
|
|||||||
else manifest.poets.filter {
|
else manifest.poets.filter {
|
||||||
it.nickname.contains(query.trim(), ignoreCase = true)
|
it.nickname.contains(query.trim(), ignoreCase = true)
|
||||||
}
|
}
|
||||||
when (sort) {
|
orderPoets(matches, sort, pinned.items)
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val padding = PaddingValues(
|
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.
|
* Pinned poets first, in the order they were pinned, then everyone else as Ganjoor has them.
|
||||||
*
|
*
|
||||||
|
|||||||
@ -65,6 +65,8 @@ data class Prefs(
|
|||||||
val oled: Boolean = false,
|
val oled: Boolean = false,
|
||||||
val poetSort: PoetSort = PoetSort.Pinned,
|
val poetSort: PoetSort = PoetSort.Pinned,
|
||||||
val poetGrid: Boolean = true,
|
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. */
|
/** 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"),
|
oled = prefs.getBoolean("oled", prefs.getString("theme", null) == "Black"),
|
||||||
poetSort = enumOrDefault(prefs.getString("poetSort", null), PoetSort.Pinned),
|
poetSort = enumOrDefault(prefs.getString("poetSort", null), PoetSort.Pinned),
|
||||||
poetGrid = prefs.getBoolean("poetGrid", true),
|
poetGrid = prefs.getBoolean("poetGrid", true),
|
||||||
|
columnsHidden = prefs.getBoolean("columnsHidden", false),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
private set
|
private set
|
||||||
@ -103,6 +106,7 @@ class Settings(context: Context) {
|
|||||||
putBoolean("oled", p.oled)
|
putBoolean("oled", p.oled)
|
||||||
putString("poetSort", p.poetSort.name)
|
putString("poetSort", p.poetSort.name)
|
||||||
putBoolean("poetGrid", p.poetGrid)
|
putBoolean("poetGrid", p.poetGrid)
|
||||||
|
putBoolean("columnsHidden", p.columnsHidden)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
147
app/src/main/java/com/ganjoor/android/ui/Skeleton.kt
Normal file
147
app/src/main/java/com/ganjoor/android/ui/Skeleton.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,6 +4,10 @@ import androidx.compose.foundation.layout.Arrangement
|
|||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
import androidx.compose.foundation.layout.Row
|
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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.foundation.layout.padding
|
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.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.HorizontalDivider
|
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.MaterialTheme
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.SuggestionChip
|
import androidx.compose.material3.SuggestionChip
|
||||||
import androidx.compose.material3.Text
|
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.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@ -78,8 +88,50 @@ 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) { CoupletFooter(passage) }
|
||||||
if (passage == null) return@WordLookup
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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))
|
HorizontalDivider(modifier = Modifier.padding(top = 8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.this_couplet),
|
text = stringResource(R.string.this_couplet),
|
||||||
@ -95,8 +147,6 @@ fun WordSheet(word: String, onDismiss: () -> Unit, passage: Bookmark? = null) {
|
|||||||
instruction = R.string.assistant_ask_prompt,
|
instruction = R.string.assistant_ask_prompt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
12
app/src/main/res/drawable/ic_menu_open.xml
Normal file
12
app/src/main/res/drawable/ic_menu_open.xml
Normal 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>
|
||||||
@ -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>
|
||||||
|
<!-- 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>
|
</resources>
|
||||||
|
|||||||
@ -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>
|
||||||
|
<!-- 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>
|
</resources>
|
||||||
|
|||||||
@ -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>
|
||||||
|
<!-- 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>
|
</resources>
|
||||||
|
|||||||
34
app/src/test/java/com/ganjoor/android/ColumnUrlsTest.kt
Normal file
34
app/src/test/java/com/ganjoor/android/ColumnUrlsTest.kt
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user