diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d8de9cb..9a8b48e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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" } diff --git a/app/src/main/java/com/ganjoor/android/MainActivity.kt b/app/src/main/java/com/ganjoor/android/MainActivity.kt index 45add11..d536cfe 100644 --- a/app/src/main/java/com/ganjoor/android/MainActivity.kt +++ b/app/src/main/java/com/ganjoor/android/MainActivity.kt @@ -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, diff --git a/app/src/main/java/com/ganjoor/android/data/PinnedPoets.kt b/app/src/main/java/com/ganjoor/android/data/PinnedPoets.kt new file mode 100644 index 0000000..dbcb2c8 --- /dev/null +++ b/app/src/main/java/com/ganjoor/android/data/PinnedPoets.kt @@ -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().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 { error("No PinnedPoets provided") } diff --git a/app/src/main/java/com/ganjoor/android/ui/PoetsScreen.kt b/app/src/main/java/com/ganjoor/android/ui/PoetsScreen.kt index 4a82537..b10ad89 100644 --- a/app/src/main/java/com/ganjoor/android/ui/PoetsScreen.kt +++ b/app/src/main/java/com/ganjoor/android/ui/PoetsScreen.kt @@ -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, pins: List): List { + if (pins.isEmpty()) return poets + val (kept, rest) = poets.partition { it.fullUrl in pins } + return kept.sortedBy { pins.indexOf(it.fullUrl) } + rest +} diff --git a/app/src/main/java/com/ganjoor/android/ui/Settings.kt b/app/src/main/java/com/ganjoor/android/ui/Settings.kt index 33a7466..483e8f2 100644 --- a/app/src/main/java/com/ganjoor/android/ui/Settings.kt +++ b/app/src/main/java/com/ganjoor/android/ui/Settings.kt @@ -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) } } } diff --git a/app/src/main/res/drawable/ic_pin.xml b/app/src/main/res/drawable/ic_pin.xml new file mode 100644 index 0000000..59e9685 --- /dev/null +++ b/app/src/main/res/drawable/ic_pin.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_view_grid.xml b/app/src/main/res/drawable/ic_view_grid.xml new file mode 100644 index 0000000..da537fa --- /dev/null +++ b/app/src/main/res/drawable/ic_view_grid.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_view_list.xml b/app/src/main/res/drawable/ic_view_list.xml new file mode 100644 index 0000000..27532e7 --- /dev/null +++ b/app/src/main/res/drawable/ic_view_list.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 1de8dfa..0b18f2c 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -107,4 +107,10 @@ این متن را ترجمه کن: این بیت گزینه‌های این بیت + برگزیده + نگه‌داشتن در بالا + برداشتن + برای نگه‌داشتن یک شاعر در اینجا، انگشت را روی او نگه دارید + نمایش کارتی + نمایش فهرستی diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml index 88b6294..43fa119 100644 --- a/app/src/main/res/values-ur/strings.xml +++ b/app/src/main/res/values-ur/strings.xml @@ -107,4 +107,10 @@ اس کا ترجمہ کریں: یہ شعر اس شعر کے اختیارات + منتخب + اوپر رکھیں + ہٹا دیں + کسی شاعر کو یہاں رکھنے کے لیے اُس پر دبا کر رکھیں + کارڈ کی صورت + فہرست کی صورت diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 44c7804..c7f3e98 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -112,4 +112,10 @@ Translate this: This couplet Options for this couplet + Pinned + Pin to the top + Unpin + Press and hold a poet to keep them here + Show as cards + Show as a list diff --git a/app/src/test/java/com/ganjoor/android/PinnedPoetsTest.kt b/app/src/test/java/com/ganjoor/android/PinnedPoetsTest.kt new file mode 100644 index 0000000..2941c2d --- /dev/null +++ b/app/src/test/java/com/ganjoor/android/PinnedPoetsTest.kt @@ -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) + } +} diff --git a/fastlane/metadata/android/en-US/changelogs/5.txt b/fastlane/metadata/android/en-US/changelogs/5.txt new file mode 100644 index 0000000..6f76539 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/5.txt @@ -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. diff --git a/fastlane/metadata/android/fa/changelogs/5.txt b/fastlane/metadata/android/fa/changelogs/5.txt new file mode 100644 index 0000000..b9cd46b --- /dev/null +++ b/fastlane/metadata/android/fa/changelogs/5.txt @@ -0,0 +1,8 @@ +شاعرانی را که می‌خوانید روی صفحهٔ نخست نگه دارید. انگشت را روی هر شاعر نگه دارید +تا برگزیده شود؛ برگزیدگان به همان ترتیبی که انتخاب کرده‌اید، نخست می‌آیند. + +صفحهٔ نخست حالا با همین قفسه باز می‌شود و ترتیب خود گنجور و فهرست الفبایی یک +ضربه فاصله دارند. تا چیزی برنگزیده‌اید، قفسه همان ترتیب گنجور است، پس هرگز +صفحه‌ای خالی نمی‌بینید. + +شاعران را می‌توان کارتی یا فهرستی دید، هرکدام که خواناتر است. diff --git a/fastlane/metadata/android/ur/changelogs/5.txt b/fastlane/metadata/android/ur/changelogs/5.txt new file mode 100644 index 0000000..5b6e2f3 --- /dev/null +++ b/fastlane/metadata/android/ur/changelogs/5.txt @@ -0,0 +1,9 @@ +جن شاعروں کو آپ پڑھتے ہیں انہیں صفحۂ اوّل پر رکھیں۔ کسی بھی شاعر پر دبا کر رکھیں +تو وہ منتخب ہو جائے گا؛ منتخب شاعر اُسی ترتیب سے سب سے پہلے آتے ہیں جس ترتیب سے +آپ نے انہیں رکھا۔ + +صفحۂ اوّل اب اسی فہرست سے کھلتا ہے، اور گنجور کی اپنی ترتیب اور حروفِ تہجی ایک +ٹیپ کی دوری پر ہیں۔ جب تک آپ کچھ منتخب نہ کریں، یہ گنجور ہی کی ترتیب رہتی ہے، +سو خالی صفحہ کبھی نہیں ملتا۔ + +شاعروں کو کارڈ کی صورت یا فہرست کی صورت، جو بہتر لگے، دیکھا جا سکتا ہے۔