Pin poets to the home screen

Three views, chosen by the chips: the poets you pinned, Ganjoor's own order,
and alphabetical. Press and hold a poet anywhere to pin or unpin — tapping
still opens them, and a pin marker sits beside the name.

Pinned is the default. With nothing pinned it is Ganjoor's order untouched, so
a fresh install never opens to an empty screen; the hint about holding a poet
shows only while the shelf is empty, because once there is something on it the
shelf explains itself.

Pins keep the order they were made in rather than being sorted. A shelf someone
arranges themselves should stay where they put it, and a new pin appearing in
the middle of the row is disorienting.

Cards or a list, switched from the app bar and remembered. The same storage as
bookmarks: a list of a few urls needs no schema.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Anas Rashid 2026-10-04 20:26:26 +02:00
parent d7568d1f49
commit c9269713d1
15 changed files with 328 additions and 41 deletions

View File

@ -16,7 +16,7 @@ val keystoreProperties = Properties().apply {
// A delivered file should say what it is without being opened.
base {
archivesName = "ganjoor-0.2.2"
archivesName = "ganjoor-0.3.0"
}
android {
@ -32,8 +32,8 @@ android {
// Pre-1.0 while the app is still being shaped. versionCode only ever climbs: F-Droid
// refuses an update that does not, and one changelog file per code lives in
// fastlane/metadata/android/*/changelogs/.
versionCode = 4
versionName = "0.2.2"
versionCode = 5
versionName = "0.3.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View File

@ -18,6 +18,8 @@ import com.ganjoor.android.data.LocalBookmarks
import com.ganjoor.android.ui.GanjoorApp
import com.ganjoor.android.data.AssistantSettings
import com.ganjoor.android.data.LocalAssistant
import com.ganjoor.android.data.LocalPinnedPoets
import com.ganjoor.android.data.PinnedPoets
import com.ganjoor.android.ui.LocalSettings
import com.ganjoor.android.ui.Settings
import com.ganjoor.android.ui.theme.GanjoorTheme
@ -56,12 +58,14 @@ class MainActivity : ComponentActivity() {
val settings = remember { settings }
val bookmarks = remember { Bookmarks(applicationContext) }
val assistant = remember { AssistantSettings(applicationContext) }
val pinned = remember { PinnedPoets(applicationContext) }
// The client reads this flag on every request, so keep it in step with the setting.
Ganjoor.offline = settings.value.offline
CompositionLocalProvider(
LocalSettings provides settings,
LocalBookmarks provides bookmarks,
LocalAssistant provides assistant,
LocalPinnedPoets provides pinned,
// The poetry is Persian, Urdu and Arabic throughout, so the whole app reads and
// navigates right-to-left whichever UI language is selected.
LocalLayoutDirection provides LayoutDirection.Rtl,

View File

@ -0,0 +1,43 @@
package com.ganjoor.android.data
import android.content.Context
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.core.content.edit
/**
* The poets someone keeps to hand, by the url that identifies them.
*
* Kept in the order they were pinned rather than sorted: a shelf someone arranges themselves
* should stay where they put it, and a new pin appearing in the middle of the row is disorienting.
*
* ponytail: the same SharedPreferences and the same shape as [Bookmarks] — a list of a few strings
* doesn't need a database or a schema, and the two have no reason to diverge.
*/
class PinnedPoets(context: Context) {
private val prefs = context.getSharedPreferences("ganjoor", Context.MODE_PRIVATE)
val items = mutableStateListOf<String>().apply {
prefs.getString(KEY, null)
?.split(SEPARATOR)
?.filter { it.isNotBlank() }
?.let(::addAll)
}
fun contains(fullUrl: String) = fullUrl in items
fun toggle(fullUrl: String) {
if (!items.remove(fullUrl)) items.add(fullUrl)
// commit, so a pin survives the process being killed right after it's made.
prefs.edit(commit = true) { putString(KEY, items.joinToString(SEPARATOR)) }
}
private companion object {
const val KEY = "pinnedPoets"
/** A newline cannot appear in a Ganjoor url, so it needs no escaping. */
const val SEPARATOR = "\n"
}
}
val LocalPinnedPoets = staticCompositionLocalOf<PinnedPoets> { error("No PinnedPoets provided") }

View File

@ -2,6 +2,10 @@ package com.ganjoor.android.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import com.ganjoor.android.data.LocalPinnedPoets
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items as listItems
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -70,6 +74,7 @@ fun PoetsScreen(
TopAppBar(
title = { Text(stringResource(R.string.app_name)) },
actions = {
ViewAction()
LanguageAction()
IconButton(onClick = onBookmarks) {
Icon(Icons.Default.Favorite, stringResource(R.string.bookmarks))
@ -92,39 +97,39 @@ fun PoetsScreen(
Load(key = Unit, block = { Ganjoor.manifest() }) { manifest ->
val settings = LocalSettings.current
val sort = settings.value.poetSort
val poets = remember(query, manifest, sort) {
val pinned = LocalPinnedPoets.current
val poets = remember(query, manifest, sort, pinned.items.toList()) {
val matches =
if (query.isBlank()) manifest.poets
else manifest.poets.filter {
it.nickname.contains(query.trim(), ignoreCase = true)
}
// Persian letters don't sort correctly by code point (آ vs ا, ی vs ي), so hand
// the ordering to a collator rather than String.compareTo.
if (sort == PoetSort.Name) {
val collator = Collator.getInstance(Locale.forLanguageTag("fa"))
matches.sortedWith { a, b -> collator.compare(a.nickname, b.nickname) }
} else {
matches
when (sort) {
// Persian letters don't sort correctly by code point (آ vs ا, ی vs ي), so hand
// the ordering to a collator rather than String.compareTo.
PoetSort.Name -> {
val collator = Collator.getInstance(Locale.forLanguageTag("fa"))
matches.sortedWith { a, b -> collator.compare(a.nickname, b.nickname) }
}
// Pinned first, in the order they were pinned, then the rest untouched. With
// nothing pinned this is Ganjoor's order, which is why it can be the default.
PoetSort.Pinned -> pinnedFirst(matches, pinned.items)
PoetSort.Default -> matches
}
}
LazyVerticalGrid(
columns = GridCells.Adaptive(132.dp),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 12.dp,
end = 12.dp,
top = insets.calculateTopPadding() + 8.dp,
bottom = insets.calculateBottomPadding() + 16.dp,
),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item(span = { GridItemSpan(maxLineSpan) }) {
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
val padding = PaddingValues(
start = 12.dp,
end = 12.dp,
top = insets.calculateTopPadding() + 8.dp,
bottom = insets.calculateBottomPadding() + 16.dp,
)
// The chips and the hint are the same whichever shape the poets take.
val header: @Composable () -> Unit = {
Column(modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
PoetSort.entries.forEach { option ->
FilterChip(
selected = sort == option,
@ -133,9 +138,51 @@ fun PoetsScreen(
)
}
}
// Only while the shelf is empty: once there is something on it, it explains
// itself, and a standing instruction is just clutter.
if (sort == PoetSort.Pinned && pinned.items.isEmpty()) {
Text(
text = stringResource(R.string.pin_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
}
}
items(poets, key = { it.id }) { poet ->
PoetCard(poet) { onPoet(poet.fullUrl) }
}
if (settings.value.poetGrid) {
LazyVerticalGrid(
columns = GridCells.Adaptive(132.dp),
modifier = Modifier.fillMaxSize(),
contentPadding = padding,
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item(span = { GridItemSpan(maxLineSpan) }) { header() }
items(poets, key = { it.id }) { poet ->
PoetCard(
poet = poet,
pinned = pinned.contains(poet.fullUrl),
onClick = { onPoet(poet.fullUrl) },
onPin = { pinned.toggle(poet.fullUrl) },
)
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = padding,
) {
item { header() }
listItems(poets, key = { it.id }) { poet ->
PoetRow(
poet = poet,
pinned = pinned.contains(poet.fullUrl),
onClick = { onPoet(poet.fullUrl) },
onPin = { pinned.toggle(poet.fullUrl) },
)
}
}
}
}
@ -176,9 +223,62 @@ private fun SearchBar(query: String, onQueryChange: (String) -> Unit, onSearchPo
}
}
/** Switches the poets between cards and a list, and remembers which. */
@Composable
private fun PoetCard(poet: PoetRef, onClick: () -> Unit) {
Card(modifier = Modifier.clickable(onClick = onClick)) {
private fun ViewAction() {
val settings = LocalSettings.current
val grid = settings.value.poetGrid
IconButton(onClick = { settings.update { it.copy(poetGrid = !grid) } }) {
Icon(
painter = painterResource(
if (grid) R.drawable.ic_view_list else R.drawable.ic_view_grid
),
contentDescription = stringResource(if (grid) R.string.view_list else R.string.view_grid),
)
}
}
/** The pin marker, shown only on a poet someone chose to keep. */
@Composable
private fun PinMark(modifier: Modifier = Modifier) {
Icon(
painter = painterResource(R.drawable.ic_pin),
contentDescription = stringResource(R.string.unpin_poet),
tint = MaterialTheme.colorScheme.primary,
modifier = modifier.size(16.dp),
)
}
/** One poet as a row, for readers who would rather scan names than faces. */
@Composable
private fun PoetRow(poet: PoetRef, pinned: Boolean, onClick: () -> Unit, onPin: () -> Unit) {
ListItem(
headlineContent = { Text(poet.nickname, style = MaterialTheme.typography.titleMedium) },
leadingContent = {
Box(
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.secondaryContainer),
contentAlignment = Alignment.Center,
) {
Text(poet.nickname.take(1), color = MaterialTheme.colorScheme.onSecondaryContainer)
AsyncImage(
model = poet.imageUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
}
},
trailingContent = { if (pinned) PinMark() },
modifier = Modifier.combinedClickable(onClick = onClick, onLongClick = onPin),
)
}
@Composable
private fun PoetCard(poet: PoetRef, pinned: Boolean, onClick: () -> Unit, onPin: () -> Unit) {
Card(modifier = Modifier.combinedClickable(onClick = onClick, onLongClick = onPin)) {
Column(
modifier = Modifier.fillMaxWidth().padding(12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
@ -204,13 +304,31 @@ private fun PoetCard(poet: PoetRef, onClick: () -> Unit) {
modifier = Modifier.fillMaxSize(),
)
}
Text(
text = poet.nickname,
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (pinned) PinMark()
Text(
text = poet.nickname,
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
/**
* Pinned poets first, in the order they were pinned, then everyone else as Ganjoor has them.
*
* With nothing pinned this is Ganjoor's order untouched, which is what lets it be the default
* without anyone opening the app to an empty screen.
*/
internal fun pinnedFirst(poets: List<PoetRef>, pins: List<String>): List<PoetRef> {
if (pins.isEmpty()) return poets
val (kept, rest) = poets.partition { it.fullUrl in pins }
return kept.sortedBy { pins.indexOf(it.fullUrl) } + rest
}

View File

@ -42,6 +42,12 @@ enum class Language(val tag: String, val label: String) {
/** How the poet grid is ordered. */
enum class PoetSort(@StringRes val label: Int) {
/**
* Whoever the reader pinned, first. With nothing pinned it is Ganjoor's order, so this can be
* the default without anyone ever opening the app to an empty screen.
*/
Pinned(R.string.sort_pinned),
/** The data set's own order, which is Ganjoor's — best known poets first. */
Default(R.string.sort_default),
Name(R.string.sort_name),
@ -57,7 +63,8 @@ data class Prefs(
val offline: Boolean = false,
/** True black backgrounds, applied to whichever dark theme is in use. */
val oled: Boolean = false,
val poetSort: PoetSort = PoetSort.Default,
val poetSort: PoetSort = PoetSort.Pinned,
val poetGrid: Boolean = true,
)
/** Reading preferences, kept in SharedPreferences and read once at startup. */
@ -75,7 +82,8 @@ class Settings(context: Context) {
offline = prefs.getBoolean("offline", false),
// "Black" used to be a sixth theme; it is a flag on the dark ones now.
oled = prefs.getBoolean("oled", prefs.getString("theme", null) == "Black"),
poetSort = enumOrDefault(prefs.getString("poetSort", null), PoetSort.Default),
poetSort = enumOrDefault(prefs.getString("poetSort", null), PoetSort.Pinned),
poetGrid = prefs.getBoolean("poetGrid", true),
)
)
private set
@ -94,6 +102,7 @@ class Settings(context: Context) {
putBoolean("offline", p.offline)
putBoolean("oled", p.oled)
putString("poetSort", p.poetSort.name)
putBoolean("poetGrid", p.poetGrid)
}
}
}

View File

@ -0,0 +1,10 @@
<!-- Material "push_pin", marking a poet kept on the home screen. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M16,9V4l1,0c0.55,0 1,-0.45 1,-1v0c0,-0.55 -0.45,-1 -1,-1H7C6.45,2 6,2.45 6,3v0c0,0.55 0.45,1 1,1l1,0v5c0,1.66 -1.34,3 -3,3h0v2h5.97v7l1,1l1,-1v-7H19v-2h0C17.34,12 16,10.66 16,9z" />
</vector>

View File

@ -0,0 +1,10 @@
<!-- Material "grid_view", for switching the poets back to cards. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M3,3v8h8V3H3zM9,9H5V5h4V9zM3,13v8h8v-8H3zM9,19H5v-4h4V19zM13,3v8h8V3H13zM19,9h-4V5h4V9zM13,13v8h8v-8H13zM19,19h-4v-4h4V19z" />
</vector>

View File

@ -0,0 +1,11 @@
<!-- Material "view_list", for switching the poets to a list of names. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:pathData="M3,14h4v-4H3V14zM3,19h4v-4H3V19zM3,9h4V5H3V9zM8,14h13v-4H8V14zM8,19h13v-4H8V19zM8,5v4h13V5H8z" />
</vector>

View File

@ -107,4 +107,10 @@
<string name="assistant_translate_prompt">این متن را ترجمه کن:</string>
<string name="this_couplet">این بیت</string>
<string name="couplet_options">گزینه‌های این بیت</string>
<string name="sort_pinned">برگزیده</string>
<string name="pin_poet">نگه‌داشتن در بالا</string>
<string name="unpin_poet">برداشتن</string>
<string name="pin_hint">برای نگه‌داشتن یک شاعر در اینجا، انگشت را روی او نگه دارید</string>
<string name="view_grid">نمایش کارتی</string>
<string name="view_list">نمایش فهرستی</string>
</resources>

View File

@ -107,4 +107,10 @@
<string name="assistant_translate_prompt">اس کا ترجمہ کریں:</string>
<string name="this_couplet">یہ شعر</string>
<string name="couplet_options">اس شعر کے اختیارات</string>
<string name="sort_pinned">منتخب</string>
<string name="pin_poet">اوپر رکھیں</string>
<string name="unpin_poet">ہٹا دیں</string>
<string name="pin_hint">کسی شاعر کو یہاں رکھنے کے لیے اُس پر دبا کر رکھیں</string>
<string name="view_grid">کارڈ کی صورت</string>
<string name="view_list">فہرست کی صورت</string>
</resources>

View File

@ -112,4 +112,10 @@
<string name="assistant_translate_prompt">Translate this:</string>
<string name="this_couplet">This couplet</string>
<string name="couplet_options">Options for this couplet</string>
<string name="sort_pinned">Pinned</string>
<string name="pin_poet">Pin to the top</string>
<string name="unpin_poet">Unpin</string>
<string name="pin_hint">Press and hold a poet to keep them here</string>
<string name="view_grid">Show as cards</string>
<string name="view_list">Show as a list</string>
</resources>

View File

@ -0,0 +1,39 @@
package com.ganjoor.android
import com.ganjoor.android.data.PoetRef
import com.ganjoor.android.ui.pinnedFirst
import org.junit.Assert.assertEquals
import org.junit.Test
class PinnedPoetsTest {
private fun poets(vararg slugs: String) =
slugs.mapIndexed { i, slug -> PoetRef(id = i, nickname = slug, fullUrl = "/$slug") }
private val all = poets("hafez", "moulavi", "saadi", "ferdousi")
/** The default view must never be empty, which is the whole reason it can be the default. */
@Test
fun `no pins leaves ganjoor's order untouched`() {
assertEquals(all, pinnedFirst(all, emptyList()))
}
@Test
fun `pinned poets come first, in the order they were pinned`() {
val order = pinnedFirst(all, listOf("/saadi", "/hafez")).map { it.nickname }
assertEquals(listOf("saadi", "hafez", "moulavi", "ferdousi"), order)
}
/** A pin for a poet who isn't in the list — filtered out by a search — must not crash. */
@Test
fun `a pin with no matching poet is ignored`() {
val order = pinnedFirst(poets("hafez"), listOf("/saadi", "/hafez")).map { it.nickname }
assertEquals(listOf("hafez"), order)
}
@Test
fun `the unpinned keep their relative order`() {
val order = pinnedFirst(all, listOf("/ferdousi")).map { it.nickname }
assertEquals(listOf("ferdousi", "hafez", "moulavi", "saadi"), order)
}
}

View File

@ -0,0 +1,8 @@
Keep the poets you read on the home screen. Press and hold any poet to pin
them; pinned poets sit first, in the order you pinned them.
The home screen now opens on that shelf, with Ganjoor's own order and an
alphabetical listing a tap away. Until you pin anything the shelf is simply
Ganjoor's order, so there is never an empty screen.
Poets can be shown as cards or as a list, whichever reads better.

View File

@ -0,0 +1,8 @@
شاعرانی را که می‌خوانید روی صفحهٔ نخست نگه دارید. انگشت را روی هر شاعر نگه دارید
تا برگزیده شود؛ برگزیدگان به همان ترتیبی که انتخاب کرده‌اید، نخست می‌آیند.
صفحهٔ نخست حالا با همین قفسه باز می‌شود و ترتیب خود گنجور و فهرست الفبایی یک
ضربه فاصله دارند. تا چیزی برنگزیده‌اید، قفسه همان ترتیب گنجور است، پس هرگز
صفحه‌ای خالی نمی‌بینید.
شاعران را می‌توان کارتی یا فهرستی دید، هرکدام که خواناتر است.

View File

@ -0,0 +1,9 @@
جن شاعروں کو آپ پڑھتے ہیں انہیں صفحۂ اوّل پر رکھیں۔ کسی بھی شاعر پر دبا کر رکھیں
تو وہ منتخب ہو جائے گا؛ منتخب شاعر اُسی ترتیب سے سب سے پہلے آتے ہیں جس ترتیب سے
آپ نے انہیں رکھا۔
صفحۂ اوّل اب اسی فہرست سے کھلتا ہے، اور گنجور کی اپنی ترتیب اور حروفِ تہجی ایک
ٹیپ کی دوری پر ہیں۔ جب تک آپ کچھ منتخب نہ کریں، یہ گنجور ہی کی ترتیب رہتی ہے،
سو خالی صفحہ کبھی نہیں ملتا۔
شاعروں کو کارڈ کی صورت یا فہرست کی صورت، جو بہتر لگے، دیکھا جا سکتا ہے۔