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/")) + } +}