From 1accb5ee78d3566cfc00ce5ca2bad70b2c49f67e Mon Sep 17 00:00:00 2001 From: Anas Rashid Date: Sun, 4 Oct 2026 00:28:52 +0200 Subject: [PATCH] Navigate the poem tree instead of the visit history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../java/com/ganjoor/android/data/Ganjoor.kt | 32 +++++++++ .../com/ganjoor/android/ui/BookmarksScreen.kt | 4 +- .../com/ganjoor/android/ui/CategoryScreen.kt | 10 ++- .../com/ganjoor/android/ui/DownloadsScreen.kt | 4 +- .../java/com/ganjoor/android/ui/GanjoorApp.kt | 69 +++++++++++++++---- .../java/com/ganjoor/android/ui/HomeAction.kt | 17 +++++ .../java/com/ganjoor/android/ui/PoemScreen.kt | 51 ++++++++++++-- app/src/main/res/values-fa/strings.xml | 1 + app/src/main/res/values-ur/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../java/com/ganjoor/android/CoupletsTest.kt | 59 ++++++++++++++++ 11 files changed, 222 insertions(+), 27 deletions(-) create mode 100644 app/src/main/java/com/ganjoor/android/ui/HomeAction.kt diff --git a/app/src/main/java/com/ganjoor/android/data/Ganjoor.kt b/app/src/main/java/com/ganjoor/android/data/Ganjoor.kt index c52abc1..eea2618 100644 --- a/app/src/main/java/com/ganjoor/android/data/Ganjoor.kt +++ b/app/src/main/java/com/ganjoor/android/data/Ganjoor.kt @@ -151,6 +151,38 @@ fun catPath(fullUrl: String) = "poets${fullUrl.trimEnd('/')}/_cat.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 { + 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 { private val dataBase = DATA_BASE.toHttpUrl() private val liveBase = "https://api.ganjoor.net/".toHttpUrl() diff --git a/app/src/main/java/com/ganjoor/android/ui/BookmarksScreen.kt b/app/src/main/java/com/ganjoor/android/ui/BookmarksScreen.kt index c695dc2..3179dcd 100644 --- a/app/src/main/java/com/ganjoor/android/ui/BookmarksScreen.kt +++ b/app/src/main/java/com/ganjoor/android/ui/BookmarksScreen.kt @@ -29,7 +29,7 @@ import com.ganjoor.android.data.LocalBookmarks @OptIn(ExperimentalMaterial3Api::class) @Composable -fun BookmarksScreen(onBack: () -> Unit, onPoem: (String) -> Unit) { +fun BookmarksScreen(onUp: () -> Unit, onPoem: (String) -> Unit) { val bookmarks = LocalBookmarks.current Scaffold( @@ -37,7 +37,7 @@ fun BookmarksScreen(onBack: () -> Unit, onPoem: (String) -> Unit) { TopAppBar( title = { Text(stringResource(R.string.bookmarks)) }, navigationIcon = { - IconButton(onClick = onBack) { + IconButton(onClick = onUp) { Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back)) } }, diff --git a/app/src/main/java/com/ganjoor/android/ui/CategoryScreen.kt b/app/src/main/java/com/ganjoor/android/ui/CategoryScreen.kt index 8ce99c3..fb61485 100644 --- a/app/src/main/java/com/ganjoor/android/ui/CategoryScreen.kt +++ b/app/src/main/java/com/ganjoor/android/ui/CategoryScreen.kt @@ -1,5 +1,6 @@ package com.ganjoor.android.ui +import androidx.activity.compose.BackHandler import androidx.compose.animation.animateContentSize import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column @@ -45,10 +46,14 @@ import com.ganjoor.android.data.Offline @Composable fun CategoryScreen( fullUrl: String, - onBack: () -> Unit, + onUp: () -> Unit, + onHome: () -> Unit, onCategory: (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 -> // First lines are a separate, optional call; the list shows up without waiting for it. var excerpts by remember(cat.id) { mutableStateOf(emptyMap()) } @@ -65,11 +70,12 @@ fun CategoryScreen( Text(cat.title, maxLines = 1, overflow = TextOverflow.Ellipsis) }, navigationIcon = { - IconButton(onClick = onBack) { + IconButton(onClick = onUp) { Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back)) } }, actions = { + HomeAction(onHome) // A whole poet can be saved for offline reading; a sub-collection can't, // because the saved tree is keyed by poet. poetSlug(fullUrl)?.let { PoetDownloadAction(it) } diff --git a/app/src/main/java/com/ganjoor/android/ui/DownloadsScreen.kt b/app/src/main/java/com/ganjoor/android/ui/DownloadsScreen.kt index f20ada3..a56360f 100644 --- a/app/src/main/java/com/ganjoor/android/ui/DownloadsScreen.kt +++ b/app/src/main/java/com/ganjoor/android/ui/DownloadsScreen.kt @@ -54,7 +54,7 @@ import com.ganjoor.android.data.PoetRef @OptIn(ExperimentalMaterial3Api::class) @Composable -fun DownloadsScreen(onBack: () -> Unit) { +fun DownloadsScreen(onUp: () -> Unit) { Load(key = Unit, block = { Ganjoor.manifest() }) { manifest -> val context = LocalContext.current // Re-read the disk whenever a download finishes or a poet is removed. @@ -76,7 +76,7 @@ fun DownloadsScreen(onBack: () -> Unit) { TopAppBar( title = { Text(stringResource(R.string.downloads)) }, navigationIcon = { - IconButton(onClick = onBack) { + IconButton(onClick = onUp) { Icon( Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back), diff --git a/app/src/main/java/com/ganjoor/android/ui/GanjoorApp.kt b/app/src/main/java/com/ganjoor/android/ui/GanjoorApp.kt index 8ea4b93..ddd63ef 100644 --- a/app/src/main/java/com/ganjoor/android/ui/GanjoorApp.kt +++ b/app/src/main/java/com/ganjoor/android/ui/GanjoorApp.kt @@ -7,10 +7,12 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.navigation.NavController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.toRoute +import com.ganjoor.android.data.parentUrl import kotlinx.serialization.Serializable // Routes carry Ganjoor URLs (/hafez/ghazal/sh1) rather than numeric ids: the data set is laid @@ -21,8 +23,12 @@ object PoetsRoute @Serializable 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 -data class PoemRoute(val url: String) +data class PoemRoute(val url: String, val fromBookmarks: Boolean = false) @Serializable object BookmarksRoute @@ -30,6 +36,28 @@ object BookmarksRoute @Serializable 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 { 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 fun GanjoorApp() { val nav = rememberNavController() @@ -48,40 +76,51 @@ fun GanjoorApp() { ) { composable { PoetsScreen( - onPoet = { nav.navigate(CategoryRoute(it)) }, + onPoet = { nav.open(CategoryRoute(it)) }, onBookmarks = { nav.navigate(BookmarksRoute) }, onDownloads = { nav.navigate(DownloadsRoute) }, ) } composable { entry -> + val url = entry.toRoute().url CategoryScreen( - fullUrl = entry.toRoute().url, - onBack = { nav.navigateUp() }, - onCategory = { nav.navigate(CategoryRoute(it)) }, - onPoem = { nav.navigate(PoemRoute(it)) }, + fullUrl = url, + onUp = { nav.goUp(url) }, + onHome = { nav.goHome() }, + onCategory = { nav.open(CategoryRoute(it)) }, + onPoem = { nav.open(PoemRoute(it)) }, ) } composable { entry -> + val route = entry.toRoute() PoemScreen( - fullUrl = entry.toRoute().url, - onBack = { nav.navigateUp() }, - // Reading on through a divan replaces the current poem, so Back returns to - // the list instead of unwinding every poem read along the way. + fullUrl = route.url, + // Reading on through a divan keeps the origin, so Back still lands where + // you started rather than in whichever section you drifted into. + onUp = { if (route.fromBookmarks) nav.navigateUp() else nav.goUp(route.url) }, + onHome = { nav.goHome() }, onPoem = { url -> - nav.navigate(PoemRoute(url)) { - popUpTo { inclusive = true } + if (route.fromBookmarks) { + nav.navigate(PoemRoute(url, fromBookmarks = true)) { + popUpTo { inclusive = true } + } + } else { + nav.open(PoemRoute(url)) } }, + // Tapping a breadcrumb leaves the saved list behind and starts browsing. + onCategory = { nav.open(CategoryRoute(it)) }, ) } composable { BookmarksScreen( - onBack = { nav.navigateUp() }, - onPoem = { nav.navigate(PoemRoute(it)) }, + onUp = { nav.goHome() }, + // Plain navigate, not open(): this keeps the saved list on the stack. + onPoem = { nav.navigate(PoemRoute(it, fromBookmarks = true)) }, ) } composable { - DownloadsScreen(onBack = { nav.navigateUp() }) + DownloadsScreen(onUp = { nav.goHome() }) } } } diff --git a/app/src/main/java/com/ganjoor/android/ui/HomeAction.kt b/app/src/main/java/com/ganjoor/android/ui/HomeAction.kt new file mode 100644 index 0000000..630e91e --- /dev/null +++ b/app/src/main/java/com/ganjoor/android/ui/HomeAction.kt @@ -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)) + } +} diff --git a/app/src/main/java/com/ganjoor/android/ui/PoemScreen.kt b/app/src/main/java/com/ganjoor/android/ui/PoemScreen.kt index 0d2d78c..9e7d603 100644 --- a/app/src/main/java/com/ganjoor/android/ui/PoemScreen.kt +++ b/app/src/main/java/com/ganjoor/android/ui/PoemScreen.kt @@ -1,7 +1,9 @@ package com.ganjoor.android.ui +import androidx.activity.compose.BackHandler import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -40,6 +42,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.ganjoor.android.R import com.ganjoor.android.data.Bookmark +import com.ganjoor.android.data.breadcrumbs import com.ganjoor.android.data.Ganjoor import com.ganjoor.android.data.LocalBookmarks import com.ganjoor.android.data.PoemRef @@ -49,7 +52,15 @@ import com.ganjoor.android.ui.theme.readingStyle @OptIn(ExperimentalMaterial3Api::class) @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 -> val prefs = LocalSettings.current.value val style = readingStyle(prefs.font, prefs.fontSize, prefs.fontWeight.weight) @@ -70,11 +81,12 @@ fun PoemScreen(fullUrl: String, onBack: () -> Unit, onPoem: (String) -> Unit) { TopAppBar( title = { Text(poem.title, maxLines = 1, overflow = TextOverflow.Ellipsis) }, navigationIcon = { - IconButton(onClick = onBack) { + IconButton(onClick = onUp) { Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back)) } }, actions = { + HomeAction(onHome) BookmarkAction( url = fullUrl, title = poem.title, @@ -99,10 +111,7 @@ fun PoemScreen(fullUrl: String, onBack: () -> Unit, onPoem: (String) -> Unit) { ) { item { Column(Modifier.padding(bottom = 12.dp)) { - Text( - text = poem.fullTitle, - style = MaterialTheme.typography.titleMedium, - ) + Breadcrumbs(poem.fullTitle, poem.fullUrl.ifBlank { fullUrl }, onCategory) poem.metre?.rhythm?.let { rhythm -> Text( 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 private fun BookmarkAction(url: String, title: String, subtitle: String) { val bookmarks = LocalBookmarks.current diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 12e790f..e30a508 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -48,4 +48,5 @@ رونوشت رونوشت شد دریافت %d شاعر + خانه diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml index 2aac68e..311f932 100644 --- a/app/src/main/res/values-ur/strings.xml +++ b/app/src/main/res/values-ur/strings.xml @@ -48,4 +48,5 @@ نقل کریں نقل ہو گیا %d شعرا ڈاؤن لوڈ کریں + ہوم diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index aef5610..2917eef 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -53,4 +53,5 @@ %s › ‹ %s Download %d poets + Home diff --git a/app/src/test/java/com/ganjoor/android/CoupletsTest.kt b/app/src/test/java/com/ganjoor/android/CoupletsTest.kt index 9ef83e0..6ba0f3d 100644 --- a/app/src/test/java/com/ganjoor/android/CoupletsTest.kt +++ b/app/src/test/java/com/ganjoor/android/CoupletsTest.kt @@ -1,6 +1,9 @@ package com.ganjoor.android +import com.ganjoor.android.data.Crumb 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 org.junit.Assert.assertEquals import org.junit.Test @@ -66,3 +69,59 @@ class CoupletsTest { 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(), breadcrumbs("حافظ » غزل ۱", "/hafez/ghazal/sh1")) + assertEquals(emptyList(), 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/")) + } +}