Navigate the poem tree instead of the visit history

Breadcrumbs: a poem's path (poet » book » section) is now built from its own
fullTitle and fullUrl and every ancestor is tappable, so the trail is the same
whether the poem was opened by browsing, from the saved list, from a
breadcrumb, or by reading on from the previous poem.

Back: browsing is a tree, so Back climbs it — poem to section to book to poet
to home — rather than retracing however you arrived. Each screen derives its
own parent from its URL and navigation keeps the stack flat, which is what
stops Back from walking forward again into the page you just left. System Back
and the top-bar arrow do the same thing.

The exception is a poem opened from the saved list: that was reached from a
list, not a shelf, so Back returns to the list, and reading on through the
divan keeps that origin.

Plus a Home button on every screen below the poet list.

Verified on an API 36 emulator: drilled Hafez > ghazals > ghazal 3 and walked
back up to the poet list, and opened a saved Khayyam rubai and confirmed Back
returns to the saved list rather than the poet's section.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Anas Rashid 2026-10-04 00:28:52 +02:00
parent abcffbc43b
commit 1accb5ee78
11 changed files with 222 additions and 27 deletions

View File

@ -151,6 +151,38 @@ fun catPath(fullUrl: String) = "poets${fullUrl.trimEnd('/')}/_cat.json"
fun poemPath(fullUrl: String) = "poets${fullUrl.trimEnd('/')}.json" fun poemPath(fullUrl: String) = "poets${fullUrl.trimEnd('/')}.json"
/** One step of a poem's path. [url] is null for the poem itself, which is already open. */
data class Crumb(val label: String, val url: String?)
/**
* Splits a poem's path into tappable ancestors, pairing the titles in `fullTitle`
* ("حافظ » غزلیات » غزل شمارهٔ ۱") with the segments of `fullUrl` ("/hafez/ghazal/sh1").
*
* Derived from the poem itself rather than the back stack, so the trail is the same whether the
* poem was opened by browsing, from a bookmark, or by reading on from the previous poem.
* Returns empty if the two don't line up, in which case the caller shows the plain title.
*/
fun breadcrumbs(fullTitle: String, fullUrl: String): List<Crumb> {
val labels = fullTitle.split('»').map { it.trim() }.filter { it.isNotEmpty() }
val segments = fullUrl.trim('/').split('/').filter { it.isNotEmpty() }
if (labels.isEmpty() || labels.size != segments.size) return emptyList()
return labels.mapIndexed { i, label ->
Crumb(
label = label,
url = if (i == labels.lastIndex) null else "/" + segments.take(i + 1).joinToString("/"),
)
}
}
/**
* The page one level up: a poem's section, a section's book, a book's poet. Null at a poet's
* root, whose parent is the poet list.
*/
fun parentUrl(fullUrl: String): String? {
val segments = fullUrl.trim('/').split('/').filter { it.isNotEmpty() }
return if (segments.size <= 1) null else "/" + segments.dropLast(1).joinToString("/")
}
object Ganjoor { object Ganjoor {
private val dataBase = DATA_BASE.toHttpUrl() private val dataBase = DATA_BASE.toHttpUrl()
private val liveBase = "https://api.ganjoor.net/".toHttpUrl() private val liveBase = "https://api.ganjoor.net/".toHttpUrl()

View File

@ -29,7 +29,7 @@ import com.ganjoor.android.data.LocalBookmarks
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun BookmarksScreen(onBack: () -> Unit, onPoem: (String) -> Unit) { fun BookmarksScreen(onUp: () -> Unit, onPoem: (String) -> Unit) {
val bookmarks = LocalBookmarks.current val bookmarks = LocalBookmarks.current
Scaffold( Scaffold(
@ -37,7 +37,7 @@ fun BookmarksScreen(onBack: () -> Unit, onPoem: (String) -> Unit) {
TopAppBar( TopAppBar(
title = { Text(stringResource(R.string.bookmarks)) }, title = { Text(stringResource(R.string.bookmarks)) },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = onUp) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back)) Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back))
} }
}, },

View File

@ -1,5 +1,6 @@
package com.ganjoor.android.ui package com.ganjoor.android.ui
import androidx.activity.compose.BackHandler
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -45,10 +46,14 @@ import com.ganjoor.android.data.Offline
@Composable @Composable
fun CategoryScreen( fun CategoryScreen(
fullUrl: String, fullUrl: String,
onBack: () -> Unit, onUp: () -> Unit,
onHome: () -> Unit,
onCategory: (String) -> Unit, onCategory: (String) -> Unit,
onPoem: (String) -> Unit, onPoem: (String) -> Unit,
) { ) {
// 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) }) { 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>()) }
@ -65,11 +70,12 @@ fun CategoryScreen(
Text(cat.title, maxLines = 1, overflow = TextOverflow.Ellipsis) Text(cat.title, maxLines = 1, overflow = TextOverflow.Ellipsis)
}, },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { 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)
// A whole poet can be saved for offline reading; a sub-collection can't, // A whole poet can be saved for offline reading; a sub-collection can't,
// because the saved tree is keyed by poet. // because the saved tree is keyed by poet.
poetSlug(fullUrl)?.let { PoetDownloadAction(it) } poetSlug(fullUrl)?.let { PoetDownloadAction(it) }

View File

@ -54,7 +54,7 @@ import com.ganjoor.android.data.PoetRef
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun DownloadsScreen(onBack: () -> Unit) { fun DownloadsScreen(onUp: () -> Unit) {
Load(key = Unit, block = { Ganjoor.manifest() }) { manifest -> Load(key = Unit, block = { Ganjoor.manifest() }) { manifest ->
val context = LocalContext.current val context = LocalContext.current
// Re-read the disk whenever a download finishes or a poet is removed. // Re-read the disk whenever a download finishes or a poet is removed.
@ -76,7 +76,7 @@ fun DownloadsScreen(onBack: () -> Unit) {
TopAppBar( TopAppBar(
title = { Text(stringResource(R.string.downloads)) }, title = { Text(stringResource(R.string.downloads)) },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = onUp) {
Icon( Icon(
Icons.AutoMirrored.Filled.ArrowBack, Icons.AutoMirrored.Filled.ArrowBack,
stringResource(R.string.back), stringResource(R.string.back),

View File

@ -7,10 +7,12 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.navigation.NavController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute import androidx.navigation.toRoute
import com.ganjoor.android.data.parentUrl
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
// Routes carry Ganjoor URLs (/hafez/ghazal/sh1) rather than numeric ids: the data set is laid // Routes carry Ganjoor URLs (/hafez/ghazal/sh1) rather than numeric ids: the data set is laid
@ -21,8 +23,12 @@ object PoetsRoute
@Serializable @Serializable
data class CategoryRoute(val url: String) data class CategoryRoute(val url: String)
/**
* [fromBookmarks] marks a poem opened from the saved list. Browsing is a tree and Back climbs
* it, but a saved poem was reached from a list, not a shelf — so Back returns to that list.
*/
@Serializable @Serializable
data class PoemRoute(val url: String) data class PoemRoute(val url: String, val fromBookmarks: Boolean = false)
@Serializable @Serializable
object BookmarksRoute object BookmarksRoute
@ -30,6 +36,28 @@ object BookmarksRoute
@Serializable @Serializable
object DownloadsRoute object DownloadsRoute
/**
* Opens a page with the poet list as the only thing beneath it.
*
* Browsing is a tree, so Back should climb it — poem to section to book to poet to home —
* rather than retrace however you arrived. Each screen works out its own parent from its URL,
* so the trail is identical whether you drilled down, followed a bookmark, tapped a breadcrumb
* or read on from the previous poem. Keeping the stack flat is what stops Back from walking
* you forward again into the page you just came from.
*/
private fun NavController.open(route: Any) = navigate(route) {
popUpTo<PoetsRoute> { inclusive = false }
launchSingleTop = true
}
private fun NavController.goHome() = open(PoetsRoute)
/** One level up the tree; from a poet's root that means home. */
private fun NavController.goUp(fromUrl: String) {
val parent = parentUrl(fromUrl)
if (parent == null) goHome() else open(CategoryRoute(parent))
}
@Composable @Composable
fun GanjoorApp() { fun GanjoorApp() {
val nav = rememberNavController() val nav = rememberNavController()
@ -48,40 +76,51 @@ fun GanjoorApp() {
) { ) {
composable<PoetsRoute> { composable<PoetsRoute> {
PoetsScreen( PoetsScreen(
onPoet = { nav.navigate(CategoryRoute(it)) }, onPoet = { nav.open(CategoryRoute(it)) },
onBookmarks = { nav.navigate(BookmarksRoute) }, onBookmarks = { nav.navigate(BookmarksRoute) },
onDownloads = { nav.navigate(DownloadsRoute) }, onDownloads = { nav.navigate(DownloadsRoute) },
) )
} }
composable<CategoryRoute> { entry -> composable<CategoryRoute> { entry ->
val url = entry.toRoute<CategoryRoute>().url
CategoryScreen( CategoryScreen(
fullUrl = entry.toRoute<CategoryRoute>().url, fullUrl = url,
onBack = { nav.navigateUp() }, onUp = { nav.goUp(url) },
onCategory = { nav.navigate(CategoryRoute(it)) }, onHome = { nav.goHome() },
onPoem = { nav.navigate(PoemRoute(it)) }, onCategory = { nav.open(CategoryRoute(it)) },
onPoem = { nav.open(PoemRoute(it)) },
) )
} }
composable<PoemRoute> { entry -> composable<PoemRoute> { entry ->
val route = entry.toRoute<PoemRoute>()
PoemScreen( PoemScreen(
fullUrl = entry.toRoute<PoemRoute>().url, fullUrl = route.url,
onBack = { nav.navigateUp() }, // Reading on through a divan keeps the origin, so Back still lands where
// Reading on through a divan replaces the current poem, so Back returns to // you started rather than in whichever section you drifted into.
// the list instead of unwinding every poem read along the way. onUp = { if (route.fromBookmarks) nav.navigateUp() else nav.goUp(route.url) },
onHome = { nav.goHome() },
onPoem = { url -> onPoem = { url ->
nav.navigate(PoemRoute(url)) { if (route.fromBookmarks) {
popUpTo<PoemRoute> { inclusive = true } nav.navigate(PoemRoute(url, fromBookmarks = true)) {
popUpTo<PoemRoute> { inclusive = true }
}
} else {
nav.open(PoemRoute(url))
} }
}, },
// Tapping a breadcrumb leaves the saved list behind and starts browsing.
onCategory = { nav.open(CategoryRoute(it)) },
) )
} }
composable<BookmarksRoute> { composable<BookmarksRoute> {
BookmarksScreen( BookmarksScreen(
onBack = { nav.navigateUp() }, onUp = { nav.goHome() },
onPoem = { nav.navigate(PoemRoute(it)) }, // Plain navigate, not open(): this keeps the saved list on the stack.
onPoem = { nav.navigate(PoemRoute(it, fromBookmarks = true)) },
) )
} }
composable<DownloadsRoute> { composable<DownloadsRoute> {
DownloadsScreen(onBack = { nav.navigateUp() }) DownloadsScreen(onUp = { nav.goHome() })
} }
} }
} }

View File

@ -0,0 +1,17 @@
package com.ganjoor.android.ui
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.ganjoor.android.R
/** Escape hatch back to the poet list, on every screen that isn't it. */
@Composable
fun HomeAction(onHome: () -> Unit) {
IconButton(onClick = onHome) {
Icon(Icons.Default.Home, stringResource(R.string.home))
}
}

View File

@ -1,7 +1,9 @@
package com.ganjoor.android.ui package com.ganjoor.android.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@ -40,6 +42,7 @@ 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.Ganjoor import com.ganjoor.android.data.Ganjoor
import com.ganjoor.android.data.LocalBookmarks import com.ganjoor.android.data.LocalBookmarks
import com.ganjoor.android.data.PoemRef import com.ganjoor.android.data.PoemRef
@ -49,7 +52,15 @@ import com.ganjoor.android.ui.theme.readingStyle
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun PoemScreen(fullUrl: String, onBack: () -> Unit, onPoem: (String) -> Unit) { fun PoemScreen(
fullUrl: String,
onUp: () -> Unit,
onHome: () -> Unit,
onPoem: (String) -> Unit,
onCategory: (String) -> Unit,
) {
BackHandler(onBack = onUp)
Load(key = fullUrl, block = { Ganjoor.poem(fullUrl) }) { poem -> Load(key = fullUrl, block = { Ganjoor.poem(fullUrl) }) { 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)
@ -70,11 +81,12 @@ fun PoemScreen(fullUrl: String, onBack: () -> Unit, onPoem: (String) -> Unit) {
TopAppBar( TopAppBar(
title = { Text(poem.title, maxLines = 1, overflow = TextOverflow.Ellipsis) }, title = { Text(poem.title, maxLines = 1, overflow = TextOverflow.Ellipsis) },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { 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)
BookmarkAction( BookmarkAction(
url = fullUrl, url = fullUrl,
title = poem.title, title = poem.title,
@ -99,10 +111,7 @@ fun PoemScreen(fullUrl: String, onBack: () -> Unit, onPoem: (String) -> Unit) {
) { ) {
item { item {
Column(Modifier.padding(bottom = 12.dp)) { Column(Modifier.padding(bottom = 12.dp)) {
Text( Breadcrumbs(poem.fullTitle, poem.fullUrl.ifBlank { fullUrl }, onCategory)
text = poem.fullTitle,
style = MaterialTheme.typography.titleMedium,
)
poem.metre?.rhythm?.let { rhythm -> poem.metre?.rhythm?.let { rhythm ->
Text( Text(
text = rhythm, text = rhythm,
@ -152,6 +161,36 @@ fun PoemScreen(fullUrl: String, onBack: () -> Unit, onPoem: (String) -> Unit) {
} }
} }
/** The poem's path, with every ancestor tappable: poet » book » section » this poem. */
@Composable
private fun Breadcrumbs(fullTitle: String, fullUrl: String, onCategory: (String) -> Unit) {
val crumbs = remember(fullTitle, fullUrl) { breadcrumbs(fullTitle, fullUrl) }
if (crumbs.isEmpty()) {
Text(fullTitle, style = MaterialTheme.typography.titleMedium)
return
}
FlowRow(verticalArrangement = Arrangement.Center) {
crumbs.forEachIndexed { index, crumb ->
if (index > 0) {
Text(
text = " » ",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
text = crumb.label,
style = MaterialTheme.typography.titleMedium,
color = if (crumb.url == null) MaterialTheme.colorScheme.onSurface
else MaterialTheme.colorScheme.primary,
modifier = if (crumb.url == null) Modifier
else Modifier.clickable { onCategory(crumb.url) },
)
}
}
}
@Composable @Composable
private fun BookmarkAction(url: String, title: String, subtitle: String) { private fun BookmarkAction(url: String, title: String, subtitle: String) {
val bookmarks = LocalBookmarks.current val bookmarks = LocalBookmarks.current

View File

@ -48,4 +48,5 @@
<string name="copy">رونوشت</string> <string name="copy">رونوشت</string>
<string name="copied">رونوشت شد</string> <string name="copied">رونوشت شد</string>
<string name="download_selected">دریافت %d شاعر</string> <string name="download_selected">دریافت %d شاعر</string>
<string name="home">خانه</string>
</resources> </resources>

View File

@ -48,4 +48,5 @@
<string name="copy">نقل کریں</string> <string name="copy">نقل کریں</string>
<string name="copied">نقل ہو گیا</string> <string name="copied">نقل ہو گیا</string>
<string name="download_selected">%d شعرا ڈاؤن لوڈ کریں</string> <string name="download_selected">%d شعرا ڈاؤن لوڈ کریں</string>
<string name="home">ہوم</string>
</resources> </resources>

View File

@ -53,4 +53,5 @@
<string name="next_poem">%s ›</string> <string name="next_poem">%s ›</string>
<string name="previous_poem">‹ %s</string> <string name="previous_poem">‹ %s</string>
<string name="download_selected">Download %d poets</string> <string name="download_selected">Download %d poets</string>
<string name="home">Home</string>
</resources> </resources>

View File

@ -1,6 +1,9 @@
package com.ganjoor.android package com.ganjoor.android
import com.ganjoor.android.data.Crumb
import com.ganjoor.android.data.Verse import com.ganjoor.android.data.Verse
import com.ganjoor.android.data.breadcrumbs
import com.ganjoor.android.data.parentUrl
import com.ganjoor.android.data.couplets import com.ganjoor.android.data.couplets
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
@ -66,3 +69,59 @@ class CoupletsTest {
assertEquals(listOf(2, 1, 1), grouped.map { it.size }) assertEquals(listOf(2, 1, 1), grouped.map { it.size })
} }
} }
class BreadcrumbsTest {
@Test
fun `every ancestor is linked and the poem itself is not`() {
val crumbs = breadcrumbs("حافظ » غزلیات » غزل شمارهٔ ۱", "/hafez/ghazal/sh1")
assertEquals(listOf("حافظ", "غزلیات", "غزل شمارهٔ ۱"), crumbs.map { it.label })
assertEquals(listOf("/hafez", "/hafez/ghazal", null), crumbs.map { it.url })
}
@Test
fun `deeply nested books keep the whole trail`() {
val crumbs = breadcrumbs(
"مولانا » مثنوی معنوی » دفتر اول » بخش ۱",
"/moulavi/masnavi/daftar1/sh1",
)
assertEquals(4, crumbs.size)
assertEquals("/moulavi/masnavi/daftar1", crumbs[2].url)
assertEquals(null, crumbs.last().url)
}
@Test
fun `a title and url that disagree produce nothing, so the caller falls back`() {
assertEquals(emptyList<Crumb>(), breadcrumbs("حافظ » غزل ۱", "/hafez/ghazal/sh1"))
assertEquals(emptyList<Crumb>(), breadcrumbs("", "/hafez/ghazal/sh1"))
}
@Test
fun `a poem directly under a poet still links the poet`() {
val crumbs = breadcrumbs("حافظ » ساقی‌نامه", "/hafez/saghinameh")
assertEquals(listOf("/hafez", null), crumbs.map { it.url })
}
}
class ParentUrlTest {
@Test
fun `each level climbs to the one above it`() {
assertEquals("/hafez/ghazal", parentUrl("/hafez/ghazal/sh1"))
assertEquals("/hafez", parentUrl("/hafez/ghazal"))
assertEquals("/moulavi/masnavi", parentUrl("/moulavi/masnavi/daftar1"))
}
@Test
fun `a poet root has no parent, so the caller sends you home`() {
assertEquals(null, parentUrl("/hafez"))
assertEquals(null, parentUrl("hafez"))
assertEquals(null, parentUrl(""))
}
@Test
fun `trailing slashes don't invent a level`() {
assertEquals("/hafez", parentUrl("/hafez/ghazal/"))
}
}