Add offline reading, bookmarks, three UI languages and downloads

Reading
- Naskh is now the default face, and both Arabic-script fonts are registered
  at four weights so the text can be thickened for a lit screen.
- Category listings show each poem's opening line, fetched best-effort from
  api.ganjoor.net since the data set doesn't carry excerpts.
- Tap a couplet to save that passage or copy it; a saved passage keeps a
  tappable link back to its poem. Poem text is selectable for plain copying.

Languages
- Persian, Urdu and English, Persian by default whatever the phone's locale,
  applied in attachBaseContext and switchable from the top bar or the sheet.
- English chrome uses Libron (OFL); poems stay naskh or nastaliq throughout.
- Each language gets its own values-* folder: with Farsi only in values/,
  Android was resolving it to the Urdu strings, since a same-script locale
  outranks the default.

Offline
- Downloaded poets mirror the data set's layout under filesDir, so offline
  mode is one lookup rather than a parallel path. Downloads run one poet at
  a time, skip what's on disk, and resume by restarting.
- Downloads screen lists every poet with their portrait and tick boxes for
  picking several, plus storage used, delete, and download-everything behind
  a size warning.
- Offline mode refuses the network and says what's missing rather than
  blaming the connection.

Also: poet sort (Ganjoor's order or alphabetical, via a Persian collator),
RTL nav transitions, an original shamsa launcher icon with a monochrome layer
for themed icons, and preferences written with commit so they survive the
process being killed.

F-Droid: dependenciesInfo off, optional signing so the build works with no
keystore, fastlane metadata in three languages, cleartext traffic disabled.

Verified on an API 36 emulator: downloaded a poet, pulled the emulator's
network, read them offline, and confirmed an undownloaded poet reports being
missing rather than erroring.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Anas RashidandClaude Opus 5 committed 2026-10-03 23:20:42 +02:00
1 parent d9915eac6c
commit abcffbc43b
66 files changed
+1581 -328

No files matched your search

+8
View File
@@ -8,3 +8,11 @@
.externalNativeBuild
.cxx
local.properties
# Local release signing — never committed; F-Droid signs with its own key.
keystore.properties
*.jks
*.keystore
# Built APKs copied out for testing
/*.apk
+89 -36
View File
@@ -1,35 +1,61 @@
# Ganjoor for Android
<div dir="rtl">گنجور — خوانندهٔ شعر پارسی برای اندروید</div>
An Android reader for [Ganjoor](https://ganjoor.net), the open archive of Persian poetry —
240 poets and ~135,000 poems, laid out for comfortable long-form reading in Persian, Urdu
and Arabic script.
and Arabic script, online or fully offline.
Jetpack Compose, Material 3, `minSdk 24`.
Jetpack Compose, Material 3, `minSdk 24`. No account, no tracking, no server of its own.
## What's here
## Reading
**Reading.** Poems render as couplets: the two hemistichs of each line stack on a phone, the
first aligned to the start of the line and the second to the end, the way Ganjoor itself reads.
Prose sections (Golestan, for example) fill the column instead. The whole app lays out
right-to-left.
Poems are set as couplets: the two hemistichs of each line stack on a phone, the first aligned
to the start of the line and the second to the end, the way Ganjoor itself reads. Prose sections
— Golestan, Nowruznameh — fill the column instead. Category listings show each poem's opening
line under its title, because "Ghazal 237" tells you nothing.
**Fonts.** Two bundled Noto families, switchable while reading:
Tap a couplet to save that passage or copy it; a saved passage keeps a tappable link back to the
poem it came from, which a plain copy would lose. The whole poem can be bookmarked from the top
bar, and any span of text can be selected and copied the usual way.
| Font | File | Note |
The whole interface lays out and navigates right-to-left, whichever UI language is chosen.
## Fonts
| Font | Used for | Licence |
|---|---|---|
| Naskh | `res/font/noto_naskh_arabic.ttf` | Covers Persian, Urdu, Arabic and Latin — also carries the UI |
| Nastaliq | `res/font/noto_nastaliq_urdu.ttf` | Traditional hanging script, needs ~2.4× leading (see `readingStyle`) |
| Noto Naskh Arabic | Poems, and the Persian/Urdu interface | OFL 1.1 |
| Noto Nastaliq Urdu | Poems, when nastaliq is selected | OFL 1.1 |
| [Libron](https://github.com/nicoverbruggen/libron) | The English interface only | OFL 1.1 |
Both are variable fonts shipped at their default weight; Android synthesises bold, since
variable axes would need API 26+. Licences are in [`licenses/`](licenses/) (SIL OFL 1.1).
Naskh is the default. Both Arabic-script faces are variable fonts registered at four weights, so
the text can be thickened — thin naskh strokes wash out on a lit screen, especially in the dark
themes. Real axis interpolation needs API 26+; below that Android synthesises the heavier
weights. Libron is a reading serif and never touches the poems: content is always naskh or
nastaliq, whatever language the interface is in.
**Themes.** Five modes, persisted across launches: System, Light, Dark, Sepia and Sepia night.
The two sepia schemes are warm paper tones for long sessions — the dark one has no blue cast.
Licences are in [`licenses/`](licenses/).
**Text size.** A slider from 14 to 40 sp with a live preview in the settings sheet.
## Themes, size, language
**Summaries.** Ganjoor publishes AI-generated summaries for some poems and couplets. They are
off by default and labelled as AI-generated wherever they appear.
Five themes, persisted: System, Light, Dark, Sepia and Sepia night. The two sepia schemes are
warm paper tones for long sessions; the dark one has no blue cast. Text size runs 14–40 sp with a
live preview in the settings sheet.
The interface speaks **Persian, Urdu and English**, Persian by default regardless of the phone's
locale. Switch from the globe in the top bar or from the settings sheet.
## Offline
Download a poet — or every poet — and read with no connection at all. Downloads are listed with
each poet's portrait and tick boxes for picking several at once; the screen shows how much space
they take and lets you delete any of them. Offline mode then refuses the network entirely and
reads only what's on the device, saying so plainly when you open something that was never
downloaded, rather than blaming your connection.
Downloads run one poet at a time and are resumable: anything already on disk is skipped, so
restarting an interrupted download picks up where it left off.
## Where the poems come from
@@ -42,11 +68,13 @@ jsDelivr's CDN, and addressed by the poem's own Ganjoor URL:
/hafez/ghazal -> poets/hafez/ghazal/_cat.json
```
Because paths are URLs, the app never touches the numeric id indexes. Responses land in a 64 MB
OkHttp disk cache, so anything already read stays readable offline.
Because paths are URLs, the app never touches the numeric id indexes. Downloaded files mirror
that same layout under `filesDir/offline`, which is why offline mode is a single lookup rather
than a parallel code path. Everything else lands in a 64 MB OkHttp disk cache.
See [`data/Ganjoor.kt`](app/src/main/java/com/ganjoor/android/data/Ganjoor.kt) — the client is
about thirty lines.
One exception: opening lines aren't in the data set, so they're fetched best-effort from
`api.ganjoor.net` and cached. Adding an `Excerpt` field to `_cat.json` upstream would remove
that dependency — see the `ponytail:` note in `Ganjoor.kt`.
## Build
@@ -55,42 +83,67 @@ about thirty lines.
./gradlew :app:testDebugUnitTest # couplet grouping tests
```
Release signing is optional. Drop a `keystore.properties` next to `settings.gradle.kts` with
`storeFile`, `storePassword`, `keyAlias` and `keyPassword` to sign locally; without it the
release build still succeeds, unsigned. The file and any `*.jks` are gitignored.
## Layout
```
data/Ganjoor.kt models, couplet grouping, the static-file client
data/Offline.kt downloaded poems on disk
data/Downloads.kt download queue and progress
data/Bookmarks.kt saved poems and passages
ui/GanjoorApp.kt nav graph; routes carry Ganjoor URLs
ui/PoetsScreen.kt poet grid with name filter
ui/PoetsScreen.kt poet grid, search, sort
ui/CategoryScreen.kt collections and poem lists
ui/PoemScreen.kt the reader
ui/ReadingSettings.kt theme / font / size sheet
ui/Settings.kt preferences
ui/Load.kt fetch + loading + retry
ui/DownloadsScreen.kt multi-select offline downloads
ui/BookmarksScreen.kt saved poems and passages
ui/ReadingSettings.kt theme / font / weight / size / language / offline
ui/theme/ colour schemes and typography
```
Single module, no DI framework, no ViewModels yet — screens fetch through `Load` and lean on the
HTTP cache. `ponytail:` comments mark the deliberate shortcuts and what would replace them.
cache. `ponytail:` comments mark the deliberate shortcuts and what would replace them.
## Not built yet
- **Search.** The data set has no search index; this needs either a client-side index or
`api.ganjoor.net`'s `/api/ganjoor/poems/search`.
- **Bookmarks and reading position.** Nothing is stored locally beyond preferences.
- **Search within poems.** The data set has no search index; this needs either a client-side
index or `api.ganjoor.net`'s `/api/ganjoor/poems/search`.
- **Reading position.** Bookmarks are saved, but not where you stopped reading.
- **Recitations.** Ganjoor has audio for many poems; it isn't in this data set.
- **Offline download.** The HTTP cache covers what you've read, not a whole divan on demand.
## Publishing to F-Droid
The build already meets the [quick start
guide](https://f-droid.org/en/docs/Submitting_to_F-Droid_Quick_Start_Guide/):
- MIT licensed, with a `LICENSE` file.
- Every dependency is FOSS (AndroidX, Kotlin, OkHttp, Coil, Accompanist). No Play Services, no
Firebase, no analytics, no trackers. Only the `INTERNET` permission, and cleartext disabled.
- `versionCode`/`versionName` are literals in `app/build.gradle.kts`, not derived from git.
- Dependency versions are all pinned; no version ranges.
- `dependenciesInfo` is switched off — that blob is signed with a Google key and isn't
reproducible, so F-Droid rejects APKs carrying it.
- The build succeeds with no keystore, so F-Droid can sign with its own key.
- Store listing lives in `fastlane/metadata/android/{en-US,fa,ur}/`. Drop screenshots into each
locale's `images/phoneScreenshots/`.
Two things to do before submitting: tag a release (`v0.1.0`), and check that F-Droid's build
server supports **AGP 9.4.1** — it is new, and that is the most likely thing to hold up a merge.
## Licensing
The app code is MIT (see [`LICENSE`](LICENSE)). Two things worth knowing about what it builds on:
The app code is MIT (see [`LICENSE`](LICENSE)). What it builds on:
- **The poetry** is classical Persian verse, long out of copyright.
- **The data set** ([`ganjoor/ganjoor-data`](https://github.com/ganjoor/ganjoor-data)) carries no
licence file. Worth asking upstream to add an explicit one.
licence file at all. Worth asking upstream to add an explicit one.
- **[GanjoorService](https://github.com/ganjoor/GanjoorService)**, Ganjoor's own backend and site,
is GPL-3.0. This app uses none of its code — only data over HTTPS — so it is not a derivative
work of it.
Fonts are SIL OFL 1.1, which permits bundling in an application.
work of it. Nothing in Ganjoor's repositories restricts AI-assisted use.
- **The icon** is original: an eight-point shamsa, the star that tiles Persian architecture.
Ganjoor's own app icons are unlicensed and are their mark, so they are not used here.
Not affiliated with or endorsed by Ganjoor.
+30
View File
@@ -1,9 +1,19 @@
import java.util.Properties
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
}
// Release signing is optional: F-Droid builds from source and signs with its own key, so the
// build has to succeed with no keystore present. Drop a keystore.properties next to settings.gradle
// (storeFile, storePassword, keyAlias, keyPassword) to sign locally; it is gitignored.
val keystoreProperties = Properties().apply {
val file = rootProject.file("keystore.properties")
if (file.exists()) file.inputStream().use { load(it) }
}
android {
namespace = "com.ganjoor.android"
compileSdk {
@@ -20,14 +30,34 @@ android {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
signingConfigs {
if (keystoreProperties.isNotEmpty()) {
create("release") {
storeFile = rootProject.file(keystoreProperties.getProperty("storeFile"))
storePassword = keystoreProperties.getProperty("storePassword")
keyAlias = keystoreProperties.getProperty("keyAlias")
keyPassword = keystoreProperties.getProperty("keyPassword")
}
}
}
buildTypes {
release {
signingConfig = signingConfigs.findByName("release")
optimization {
enable = true
packageScope = setOf("androidx.**", "kotlin.**", "kotlinx.**")
}
}
}
// The dependency metadata block is signed with a Google key and is not reproducible, so
// F-Droid rejects APKs that carry it.
dependenciesInfo {
includeInApk = false
includeInBundle = false
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
+1
View File
@@ -12,6 +12,7 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:usesCleartextTraffic="false"
android:theme="@style/Theme.Ganjoorappandroid">
<activity
android:name=".MainActivity"
@@ -1,5 +1,7 @@
package com.ganjoor.android
import android.content.Context
import android.content.res.Configuration
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
@@ -8,25 +10,45 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.LayoutDirection
import com.ganjoor.android.data.Bookmarks
import com.ganjoor.android.data.Ganjoor
import com.ganjoor.android.data.LocalBookmarks
import com.ganjoor.android.ui.GanjoorApp
import com.ganjoor.android.ui.LocalSettings
import com.ganjoor.android.ui.Settings
import com.ganjoor.android.ui.theme.GanjoorTheme
import java.util.Locale
class MainActivity : ComponentActivity() {
/**
* The UI language is the app's own setting, not the phone's — Farsi unless chosen otherwise.
* Applied here because resources are resolved before anything else runs; changing the
* setting calls [recreate], which comes back through this method.
*/
override fun attachBaseContext(newBase: Context) {
val config = Configuration(newBase.resources.configuration)
config.setLocale(Locale.forLanguageTag(Settings.language(newBase).tag))
super.attachBaseContext(newBase.createConfigurationContext(config))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Ganjoor.init(applicationContext)
enableEdgeToEdge()
setContent {
val settings = remember { Settings(applicationContext) }
val bookmarks = remember { Bookmarks(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,
// The content is Persian/Urdu/Arabic throughout, so the whole app is RTL.
LocalBookmarks provides bookmarks,
// 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,
) {
GanjoorTheme(settings.value.theme) {
GanjoorTheme(settings.value.theme, settings.value.language) {
GanjoorApp()
}
}
@@ -0,0 +1,56 @@
package com.ganjoor.android.data
import android.content.Context
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.core.content.edit
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
/**
* A saved poem, or a saved passage from one. Either way [url] points back at the poem it came
* from, so every entry in the list stays tappable.
*/
@Serializable
data class Bookmark(
val url: String,
val title: String,
val subtitle: String = "",
val excerpt: String? = null,
) {
/** Several passages can be saved from one poem, so the url alone can't be the identity. */
val id: String get() = if (excerpt == null) url else "$url#$excerpt"
}
/**
* Bookmarked poems, kept on the device. A handful of lines of JSON in SharedPreferences —
* ponytail: a database for a list someone scrolls by hand isn't worth the schema.
*/
class Bookmarks(context: Context) {
private val prefs = context.getSharedPreferences("ganjoor", Context.MODE_PRIVATE)
private val json = Json { ignoreUnknownKeys = true }
val items = mutableStateListOf<Bookmark>().apply {
val stored = prefs.getString(KEY, null)
if (stored != null) {
addAll(runCatching { json.decodeFromString<List<Bookmark>>(stored) }.getOrDefault(emptyList()))
}
}
/** Whether the whole poem is bookmarked — passages saved from it don't count. */
fun contains(url: String) = items.any { it.url == url && it.excerpt == null }
fun contains(bookmark: Bookmark) = items.any { it.id == bookmark.id }
fun toggle(bookmark: Bookmark) {
if (!items.removeAll { it.id == bookmark.id }) items.add(0, bookmark)
// commit, so a bookmark survives the process being killed right after it's made.
prefs.edit(commit = true) { putString(KEY, json.encodeToString(items.toList())) }
}
private companion object {
const val KEY = "bookmarks"
}
}
val LocalBookmarks = staticCompositionLocalOf<Bookmarks> { error("No Bookmarks provided") }
@@ -0,0 +1,66 @@
package com.ganjoor.android.data
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* Tracks poet downloads so the UI can show progress.
*
* ponytail: plain coroutines, no WorkManager — a download stops when the process dies. Starting
* it again is cheap and safe, because [Ganjoor.downloadPoet] skips anything already on disk.
*/
object Downloads {
/** [total] is 0 until the poet's tree has been walked, i.e. while still queued. */
data class Progress(val done: Int, val total: Int)
/** Slug -> progress, for downloads running right now. */
val running = mutableStateMapOf<String, Progress>()
/** Bumped whenever a download finishes or a poet is deleted, so screens re-read the disk. */
var revision by mutableStateOf(0)
private set
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val jobs = mutableMapOf<String, Job>()
// One poet at a time. "Download every poet" queues 240 of them, and letting those run at
// once would open thousands of connections and finish no sooner.
private val turn = Mutex()
fun start(slug: String) {
if (jobs[slug]?.isActive == true) return
running[slug] = Progress(0, 0)
jobs[slug] = scope.launch {
try {
turn.withLock {
Ganjoor.downloadPoet(slug) { done, total ->
running[slug] = Progress(done, total)
}
}
} finally {
running.remove(slug)
revision++
}
}
}
fun cancel(slug: String) {
jobs.remove(slug)?.cancel()
running.remove(slug)
}
fun delete(slug: String) {
cancel(slug)
Offline.delete(slug)
revision++
}
}
@@ -2,6 +2,9 @@ package com.ganjoor.android.data
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
@@ -9,6 +12,7 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNamingStrategy
import okhttp3.Cache
import okhttp3.CacheControl
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
@@ -131,23 +135,45 @@ fun List<Verse>.couplets(): List<List<Verse>> {
return out
}
/** First lines come from ganjoor.net's live API, which uses camelCase. */
@Serializable
private data class LiveCatPage(val cat: LiveCat? = null)
@Serializable
private data class LiveCat(val poems: List<LivePoem> = emptyList())
@Serializable
private data class LivePoem(val id: Int = 0, val excerpt: String? = null)
private val liveJson = Json { ignoreUnknownKeys = true }
fun catPath(fullUrl: String) = "poets${fullUrl.trimEnd('/')}/_cat.json"
fun poemPath(fullUrl: String) = "poets${fullUrl.trimEnd('/')}.json"
object Ganjoor {
private val base = DATA_BASE.toHttpUrl()
private val dataBase = DATA_BASE.toHttpUrl()
private val liveBase = "https://api.ganjoor.net/".toHttpUrl()
private lateinit var http: OkHttpClient
/** When set, nothing leaves the device: only downloaded pages open. */
@Volatile
var offline = false
fun init(context: Context) {
Offline.init(context)
if (::http.isInitialized) return
// jsDelivr serves these with a long max-age, so this disk cache is what makes already
// visited poems readable offline. ponytail: no Room mirror until "download a poet" exists.
// jsDelivr serves these with a long max-age, so this cache covers casual re-reading.
// Anything that has to survive for certain goes through Offline instead.
http = OkHttpClient.Builder()
.cache(Cache(File(context.cacheDir, "ganjoor-data"), 64L * 1024 * 1024))
.build()
}
private suspend inline fun <reified T> get(path: String): T = withContext(Dispatchers.IO) {
val request = Request.Builder()
.url(base.newBuilder().addPathSegments(path).build())
.build()
private fun dataUrl(path: String) = dataBase.newBuilder().addPathSegments(path).build()
private fun fetch(url: HttpUrl): String {
val request = Request.Builder().url(url).build()
val response = try {
http.newCall(request).execute()
} catch (offline: IOException) {
@@ -155,19 +181,102 @@ object Ganjoor {
http.newCall(request.newBuilder().cacheControl(CacheControl.FORCE_CACHE).build())
.execute()
}
response.use {
if (!it.isSuccessful) throw IOException("HTTP ${it.code} for $path")
json.decodeFromString<T>(it.body!!.string())
return response.use {
if (!it.isSuccessful) throw IOException("HTTP ${it.code} for $url")
it.body!!.string()
}
}
suspend fun manifest(): Manifest = get("manifest.json")
/** Downloaded copy first, then the network — unless offline mode rules the network out. */
private suspend inline fun <reified T> load(path: String): T = withContext(Dispatchers.IO) {
Offline.read(path)?.let { return@withContext json.decodeFromString<T>(it) }
if (offline) throw NotDownloaded(path)
json.decodeFromString<T>(fetch(dataUrl(path)))
}
suspend fun poet(slug: String): Poet = get("poets/$slug/poet.json")
/** Like [load], but keeps the body on disk. Already-saved paths cost nothing. */
private suspend fun grab(path: String): String = withContext(Dispatchers.IO) {
Offline.read(path) ?: fetch(dataUrl(path)).also { Offline.write(path, it) }
}
// Kept on disk from the first fetch: without the poet list, offline mode has no way in.
suspend fun manifest(): Manifest = json.decodeFromString(grab("manifest.json"))
suspend fun poet(slug: String): Poet = load("poets/$slug/poet.json")
/** @param fullUrl a Ganjoor category URL, e.g. `/hafez/ghazal` (or `/hafez` for a poet's root). */
suspend fun category(fullUrl: String): Category = get("poets${fullUrl.trimEnd('/')}/_cat.json")
suspend fun category(fullUrl: String): Category = load(catPath(fullUrl))
/** @param fullUrl a Ganjoor poem URL, e.g. `/hafez/ghazal/sh1`. */
suspend fun poem(fullUrl: String): Poem = get("poets${fullUrl.trimEnd('/')}.json")
suspend fun poem(fullUrl: String): Poem = load(poemPath(fullUrl))
/**
* Poem id -> opening line, for a category listing.
*
* The data set's `_cat.json` carries only id, title and url, so this is a best-effort call
* to ganjoor.net's live API; the list renders without it and fills in when it lands. The
* result is kept on disk so downloaded poets still show their first lines offline.
*
* ponytail: delete this the day ganjoor-data's `_cat.json` gains an Excerpt field.
*/
suspend fun excerpts(catId: Int): Map<Int, String> = withContext(Dispatchers.IO) {
val path = "excerpts/$catId.json"
Offline.read(path)?.let {
return@withContext runCatching {
liveJson.decodeFromString<Map<Int, String>>(it)
}.getOrDefault(emptyMap())
}
if (offline) return@withContext emptyMap()
val url = liveBase.newBuilder()
.addPathSegments("api/ganjoor/cat/$catId")
.addQueryParameter("poems", "true")
.build()
runCatching {
liveJson.decodeFromString<LiveCatPage>(fetch(url)).cat?.poems.orEmpty()
.mapNotNull { poem -> poem.excerpt?.takeIf { it.isNotBlank() }?.let { poem.id to it } }
.toMap()
}.onSuccess {
if (it.isNotEmpty()) Offline.write(path, liveJson.encodeToString(it))
}.getOrDefault(emptyMap())
}
/**
* Saves a poet's whole tree — biography, every category and every poem — for offline reading.
* Re-running it is cheap: anything already on disk is skipped, which is also how a download
* interrupted by the process dying gets finished.
*/
suspend fun downloadPoet(slug: String, onProgress: (done: Int, total: Int) -> Unit) {
val wasOffline = offline
offline = false
try {
runCatching { grab("poets/$slug/poet.json") }
val poemPaths = mutableListOf<String>()
val catIds = mutableListOf<Int>()
suspend fun walk(fullUrl: String) {
val cat = json.decodeFromString<Category>(grab(catPath(fullUrl)))
catIds += cat.id
cat.poems.forEach { poemPaths += poemPath(it.fullUrl) }
cat.childCats.forEach { walk(it.fullUrl) }
}
walk("/$slug")
onProgress(0, poemPaths.size)
var done = 0
// Six at a time: enough to keep the CDN busy without opening a connection per poem.
poemPaths.chunked(6).forEach { chunk ->
coroutineScope {
chunk.map { path -> async { runCatching { grab(path) } } }.awaitAll()
}
done += chunk.size
onProgress(done, poemPaths.size)
}
catIds.forEach { runCatching { excerpts(it) } }
} finally {
offline = wasOffline
}
}
}
@@ -0,0 +1,47 @@
package com.ganjoor.android.data
import android.content.Context
import java.io.File
/**
* Poems saved to the device. Mirrors the data set's own layout under `filesDir/offline`, so a
* saved file is found by the same path [Ganjoor] would fetch — which is what lets offline mode
* be a single check rather than a parallel code path.
*/
object Offline {
private lateinit var root: File
fun init(context: Context) {
if (::root.isInitialized) return
root = File(context.filesDir, "offline")
}
fun read(path: String): String? = File(root, path).takeIf { it.isFile }?.readText()
fun write(path: String, body: String) {
val file = File(root, path)
file.parentFile?.mkdirs()
file.writeText(body)
}
private fun poetDir(slug: String) = File(root, "poets/$slug")
fun isSaved(slug: String) = poetDir(slug).isDirectory
/** Poem files only — `_cat.json` is structure, not something you read. */
fun poemCount(slug: String): Int =
poetDir(slug).walkTopDown().count { it.isFile && it.name != "_cat.json" }
fun savedSlugs(): List<String> =
File(root, "poets").listFiles()?.filter { it.isDirectory }?.map { it.name }?.sorted()
?: emptyList()
fun delete(slug: String) {
poetDir(slug).deleteRecursively()
}
fun bytes(): Long = root.walkTopDown().filter { it.isFile }.sumOf { it.length() }
}
/** Thrown when offline mode is on and the requested page was never downloaded. */
class NotDownloaded(path: String) : Exception("Not downloaded: $path")
@@ -0,0 +1,86 @@
package com.ganjoor.android.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.ganjoor.android.R
import com.ganjoor.android.data.LocalBookmarks
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BookmarksScreen(onBack: () -> Unit, onPoem: (String) -> Unit) {
val bookmarks = LocalBookmarks.current
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.bookmarks)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back))
}
},
)
}
) { insets ->
if (bookmarks.items.isEmpty()) {
Box(Modifier.fillMaxSize().padding(insets), Alignment.Center) {
Text(
text = stringResource(R.string.no_bookmarks),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
return@Scaffold
}
LazyColumn(modifier = Modifier.fillMaxSize(), contentPadding = insets) {
items(bookmarks.items, key = { it.id }) { bookmark ->
ListItem(
// A saved passage leads with its own words; a saved poem with its title.
headlineContent = {
Text(
text = bookmark.excerpt ?: bookmark.title,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
},
supportingContent = bookmark.subtitle.takeIf { it.isNotBlank() }?.let {
{ Text(it, maxLines = 1, overflow = TextOverflow.Ellipsis) }
},
trailingContent = {
IconButton(onClick = { bookmarks.toggle(bookmark) }) {
Icon(
imageVector = Icons.Default.Favorite,
contentDescription = stringResource(R.string.bookmark_remove),
tint = MaterialTheme.colorScheme.primary,
)
}
},
modifier = Modifier.clickable { onPoem(bookmark.url) },
)
HorizontalDivider()
}
}
}
}
@@ -7,12 +7,15 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@@ -23,16 +26,20 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.ganjoor.android.R
import com.ganjoor.android.data.Downloads
import com.ganjoor.android.data.Ganjoor
import com.ganjoor.android.data.Offline
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -43,6 +50,14 @@ fun CategoryScreen(
onPoem: (String) -> Unit,
) {
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<Int, String>()) }
LaunchedEffect(cat.id) {
if (cat.poems.isNotEmpty()) {
excerpts = runCatching { Ganjoor.excerpts(cat.id) }.getOrDefault(emptyMap())
}
}
Scaffold(
topBar = {
TopAppBar(
@@ -54,7 +69,12 @@ fun CategoryScreen(
Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back))
}
},
actions = { ReadingSettingsAction() },
actions = {
// 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) }
ReadingSettingsAction()
},
)
}
) { insets ->
@@ -72,17 +92,65 @@ fun CategoryScreen(
NavRow(child.title, isCategory = true) { onCategory(child.fullUrl) }
}
items(cat.poems, key = { "p${it.id}" }) { poem ->
NavRow(poem.title, isCategory = false) { onPoem(poem.fullUrl) }
NavRow(poem.title, isCategory = false, excerpt = excerpts[poem.id]) {
onPoem(poem.fullUrl)
}
}
}
}
}
}
/** A poet's root URL is a single segment (`/hafez`); anything deeper is one of their books. */
private fun poetSlug(fullUrl: String): String? =
fullUrl.trim('/').takeIf { it.isNotEmpty() && !it.contains('/') }
@Composable
private fun NavRow(title: String, isCategory: Boolean, onClick: () -> Unit) {
private fun PoetDownloadAction(slug: String) {
val progress = Downloads.running[slug]
val saved = remember(slug, Downloads.revision) { Offline.isSaved(slug) }
when {
progress != null -> IconButton(onClick = { Downloads.cancel(slug) }) {
if (progress.total == 0) {
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
} else {
CircularProgressIndicator(
progress = { progress.done.toFloat() / progress.total },
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
)
}
}
saved -> IconButton(onClick = { Downloads.delete(slug) }) {
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = stringResource(R.string.delete_download),
tint = MaterialTheme.colorScheme.primary,
)
}
// Core Material icons ship no download glyph, and the extended set is 4 MB for one icon.
else -> IconButton(onClick = { Downloads.start(slug) }) {
Icon(painterResource(R.drawable.ic_download), stringResource(R.string.download))
}
}
}
@Composable
private fun NavRow(
title: String,
isCategory: Boolean,
excerpt: String? = null,
onClick: () -> Unit,
) {
ListItem(
headlineContent = { Text(title) },
// The opening line says what a poem is about far better than "Ghazal 237" does.
supportingContent = excerpt?.takeIf { it.isNotBlank() }?.let {
{ Text(it, maxLines = 1, overflow = TextOverflow.Ellipsis) }
},
// A collection drills down into more lists; a poem is the leaf you read.
leadingContent = if (!isCategory) null else {
{
@@ -0,0 +1,229 @@
package com.ganjoor.android.ui
import android.text.format.Formatter
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.ganjoor.android.R
import com.ganjoor.android.data.Downloads
import com.ganjoor.android.data.Ganjoor
import com.ganjoor.android.data.Offline
import com.ganjoor.android.data.PoetRef
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DownloadsScreen(onBack: () -> 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.
val revision = Downloads.revision
val saved = remember(revision) { Offline.savedSlugs().toSet() }
val bytes = remember(revision) { Offline.bytes() }
val selected = remember { mutableStateListOf<String>() }
var query by remember { mutableStateOf("") }
var confirmAll by remember { mutableStateOf(false) }
val poets = remember(query, manifest) {
if (query.isBlank()) manifest.poets
else manifest.poets.filter { it.nickname.contains(query.trim(), ignoreCase = true) }
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.downloads)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
stringResource(R.string.back),
)
}
},
)
},
bottomBar = {
// Only takes up room once something is ticked.
if (selected.isNotEmpty()) {
Surface(tonalElevation = 3.dp) {
Button(
onClick = {
selected.forEach { Downloads.start(it) }
selected.clear()
},
modifier = Modifier
.navigationBarsPadding()
.fillMaxWidth()
.padding(16.dp),
) {
Text(stringResource(R.string.download_selected, selected.size))
}
}
}
},
) { insets ->
LazyColumn(modifier = Modifier.fillMaxSize(), contentPadding = insets) {
item {
Column(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = stringResource(
R.string.storage_used,
Formatter.formatShortFileSize(context, bytes),
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedButton(
onClick = { confirmAll = true },
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.download_all))
}
OutlinedTextField(
value = query,
onValueChange = { query = it },
label = { Text(stringResource(R.string.search_poets)) },
leadingIcon = { Icon(Icons.Default.Search, null) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
HorizontalDivider()
}
items(poets, key = { it.id }) { poet ->
PoetRow(
poet = poet,
progress = Downloads.running[poet.slug],
isSaved = poet.slug in saved,
isSelected = poet.slug in selected,
revision = revision,
onToggleSelected = {
if (!selected.remove(poet.slug)) selected.add(poet.slug)
},
)
HorizontalDivider()
}
}
if (confirmAll) {
AlertDialog(
onDismissRequest = { confirmAll = false },
title = { Text(stringResource(R.string.download_all)) },
text = { Text(stringResource(R.string.download_all_warning)) },
confirmButton = {
TextButton(onClick = {
confirmAll = false
manifest.poets.forEach { Downloads.start(it.slug) }
}) { Text(stringResource(R.string.download)) }
},
dismissButton = {
TextButton(onClick = { confirmAll = false }) {
Text(stringResource(R.string.cancel))
}
},
)
}
}
}
}
/**
* One poet. The trailing control is whichever of the three states the poet is in: being fetched,
* already on the device, or available to tick for the next batch.
*/
@Composable
private fun PoetRow(
poet: PoetRef,
progress: Downloads.Progress?,
isSaved: Boolean,
isSelected: Boolean,
revision: Int,
onToggleSelected: () -> Unit,
) {
val poems = remember(poet.slug, revision) { if (isSaved) Offline.poemCount(poet.slug) else 0 }
ListItem(
headlineContent = { Text(poet.nickname) },
supportingContent = when {
progress != null && progress.total > 0 -> {
{ Text(stringResource(R.string.downloading, progress.done, progress.total)) }
}
isSaved -> {
{ Text(stringResource(R.string.downloaded, poems)) }
}
else -> null
},
leadingContent = {
AsyncImage(
model = poet.imageUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(44.dp).clip(CircleShape),
)
},
trailingContent = {
when {
progress != null -> IconButton(onClick = { Downloads.cancel(poet.slug) }) {
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
}
isSaved -> IconButton(onClick = { Downloads.delete(poet.slug) }) {
Icon(Icons.Default.Delete, stringResource(R.string.delete_download))
}
else -> Checkbox(checked = isSelected, onCheckedChange = { onToggleSelected() })
}
},
modifier = if (progress != null || isSaved) Modifier
else Modifier.clickable(onClick = onToggleSelected),
)
}
@@ -1,5 +1,6 @@
package com.ganjoor.android.ui
import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
@@ -23,15 +24,34 @@ data class CategoryRoute(val url: String)
@Serializable
data class PoemRoute(val url: String)
@Serializable
object BookmarksRoute
@Serializable
object DownloadsRoute
@Composable
fun GanjoorApp() {
val nav = rememberNavController()
var settingsOpen by rememberSaveable { mutableStateOf(false) }
CompositionLocalProvider(LocalOpenReadingSettings provides { settingsOpen = true }) {
NavHost(navController = nav, startDestination = PoetsRoute) {
NavHost(
navController = nav,
startDestination = PoetsRoute,
// Start/End rather than Left/Right, so going deeper always moves against the reading
// direction — leftwards here, since the app lays out right-to-left.
enterTransition = { slideIntoContainer(SlideDirection.Start) },
exitTransition = { slideOutOfContainer(SlideDirection.Start) },
popEnterTransition = { slideIntoContainer(SlideDirection.End) },
popExitTransition = { slideOutOfContainer(SlideDirection.End) },
) {
composable<PoetsRoute> {
PoetsScreen(onPoet = { nav.navigate(CategoryRoute(it)) })
PoetsScreen(
onPoet = { nav.navigate(CategoryRoute(it)) },
onBookmarks = { nav.navigate(BookmarksRoute) },
onDownloads = { nav.navigate(DownloadsRoute) },
)
}
composable<CategoryRoute> { entry ->
CategoryScreen(
@@ -54,6 +74,15 @@ fun GanjoorApp() {
},
)
}
composable<BookmarksRoute> {
BookmarksScreen(
onBack = { nav.navigateUp() },
onPoem = { nav.navigate(PoemRoute(it)) },
)
}
composable<DownloadsRoute> {
DownloadsScreen(onBack = { nav.navigateUp() })
}
}
}
@@ -0,0 +1,52 @@
package com.ganjoor.android.ui
import android.app.Activity
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import com.ganjoor.android.R
/** Top-bar language picker. The same choice also lives in the reading settings sheet. */
@Composable
fun LanguageAction() {
val settings = LocalSettings.current
val context = LocalContext.current
var open by remember { mutableStateOf(false) }
IconButton(onClick = { open = true }) {
Icon(painterResource(R.drawable.ic_language), stringResource(R.string.language))
}
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
Language.entries.forEach { language ->
val selected = settings.value.language == language
DropdownMenuItem(
text = {
Text(
text = language.label,
color = if (selected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface,
)
},
onClick = {
open = false
if (!selected) {
settings.update { it.copy(language = language) }
// Resources are picked in attachBaseContext, so the activity restarts.
(context as? Activity)?.recreate()
}
},
)
}
}
}
@@ -21,12 +21,13 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.ganjoor.android.R
import com.ganjoor.android.data.NotDownloaded
/**
* Fetches [block] whenever [key] changes and renders loading / error / content.
*
* ponytail: no ViewModel, so going back re-fetches — which the OkHttp disk cache makes nearly
* free. Promote to a ViewModel when a screen gains state worth surviving rotation.
* ponytail: no ViewModel, so going back re-fetches — which the disk cache makes nearly free.
* Promote to a ViewModel when a screen gains state worth surviving rotation.
*/
@Composable
fun <T> Load(key: Any?, block: suspend () -> T, content: @Composable (T) -> Unit) {
@@ -45,7 +46,12 @@ fun <T> Load(key: Any?, block: suspend () -> T, content: @Composable (T) -> Unit
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(R.string.load_failed),
text = stringResource(
// Offline mode failing on a page nobody downloaded isn't a network error,
// and telling someone to check their connection would be a dead end.
if (outcome.exceptionOrNull() is NotDownloaded) R.string.load_failed_offline
else R.string.load_failed
),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
)
@@ -1,5 +1,6 @@
package com.ganjoor.android.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -9,12 +10,16 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@@ -27,12 +32,16 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextAlign
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.Ganjoor
import com.ganjoor.android.data.LocalBookmarks
import com.ganjoor.android.data.PoemRef
import com.ganjoor.android.data.Verse
import com.ganjoor.android.data.couplets
@@ -43,7 +52,7 @@ import com.ganjoor.android.ui.theme.readingStyle
fun PoemScreen(fullUrl: String, onBack: () -> Unit, onPoem: (String) -> Unit) {
Load(key = fullUrl, block = { Ganjoor.poem(fullUrl) }) { poem ->
val prefs = LocalSettings.current.value
val style = readingStyle(prefs.font, prefs.fontSize)
val style = readingStyle(prefs.font, prefs.fontSize, prefs.fontWeight.weight)
val couplets = remember(poem) { poem.verses.couplets() }
// Siblings come from the parent category, which is a much bigger file than the poem, so
@@ -65,10 +74,20 @@ fun PoemScreen(fullUrl: String, onBack: () -> Unit, onPoem: (String) -> Unit) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back))
}
},
actions = { ReadingSettingsAction() },
actions = {
BookmarkAction(
url = fullUrl,
title = poem.title,
subtitle = poem.fullTitle,
)
ReadingSettingsAction()
},
)
}
) { insets ->
// Free-form selection for copying any span; the per-couplet actions below are for
// saving a passage with the reference attached, which a raw copy would lose.
SelectionContainer {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
@@ -94,7 +113,14 @@ fun PoemScreen(fullUrl: String, onBack: () -> Unit, onPoem: (String) -> Unit) {
}
}
items(couplets) { couplet -> Couplet(couplet, style, prefs.showSummaries) }
items(couplets) { couplet ->
Couplet(
couplet = couplet,
style = style,
showSummaries = prefs.showSummaries,
source = Bookmark(fullUrl, poem.title, poem.fullTitle),
)
}
if (prefs.showSummaries) {
poem.poemSummary?.takeIf { it.isNotBlank() }?.let { summary ->
@@ -121,10 +147,26 @@ fun PoemScreen(fullUrl: String, onBack: () -> Unit, onPoem: (String) -> Unit) {
}
}
}
}
}
}
}
@Composable
private fun BookmarkAction(url: String, title: String, subtitle: String) {
val bookmarks = LocalBookmarks.current
val saved = bookmarks.contains(url)
IconButton(onClick = { bookmarks.toggle(Bookmark(url, title, subtitle)) }) {
Icon(
imageVector = if (saved) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
contentDescription = stringResource(
if (saved) R.string.bookmark_remove else R.string.bookmark_add
),
tint = if (saved) MaterialTheme.colorScheme.primary else LocalContentColor.current,
)
}
}
/**
* One line of poetry. A couplet is two hemistichs; on a phone they stack, with the first pushed
* to the start of the line and the second to the end, which is how Ganjoor itself reads.
@@ -134,8 +176,17 @@ private fun Couplet(
couplet: List<Verse>,
style: androidx.compose.ui.text.TextStyle,
showSummaries: Boolean,
source: Bookmark,
) {
Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
// Tap, not long-press: long-press belongs to the text selection this sits inside.
var actionsOpen by remember(couplet) { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxWidth()
.clickable { actionsOpen = !actionsOpen }
.padding(vertical = 6.dp)
) {
couplet.forEach { verse ->
Text(
text = verse.text,
@@ -162,6 +213,34 @@ private fun Couplet(
)
}
}
if (actionsOpen) {
PassageActions(source.copy(excerpt = couplet.joinToString("\n") { it.text }))
}
}
}
/** Save this passage, or copy it. Saving keeps the link back to the poem; copying doesn't. */
@Composable
private fun PassageActions(passage: Bookmark) {
val bookmarks = LocalBookmarks.current
val clipboard = LocalClipboardManager.current
val saved = bookmarks.contains(passage)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(onClick = { bookmarks.toggle(passage) }) {
Text(
text = stringResource(
if (saved) R.string.saved_passage else R.string.save_passage
),
color = if (saved) MaterialTheme.colorScheme.primary
else LocalContentColor.current,
)
}
TextButton(onClick = {
clipboard.setText(AnnotatedString(passage.excerpt.orEmpty()))
}) {
Text(stringResource(R.string.copy))
}
}
}
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -16,10 +17,13 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Card
import androidx.compose.material3.FilterChip
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
@@ -34,31 +38,56 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import java.text.Collator
import java.util.Locale
import com.ganjoor.android.R
import com.ganjoor.android.data.Ganjoor
import com.ganjoor.android.data.PoetRef
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PoetsScreen(onPoet: (String) -> Unit) {
fun PoetsScreen(onPoet: (String) -> Unit, onBookmarks: () -> Unit, onDownloads: () -> Unit) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.app_name)) },
actions = { ReadingSettingsAction() },
actions = {
LanguageAction()
IconButton(onClick = onBookmarks) {
Icon(Icons.Default.Favorite, stringResource(R.string.bookmarks))
}
IconButton(onClick = onDownloads) {
Icon(painterResource(R.drawable.ic_download), stringResource(R.string.downloads))
}
ReadingSettingsAction()
},
)
}
) { insets ->
Load(key = Unit, block = { Ganjoor.manifest() }) { manifest ->
val settings = LocalSettings.current
val sort = settings.value.poetSort
var query by remember { mutableStateOf("") }
val poets = remember(query, manifest) {
if (query.isBlank()) manifest.poets
else manifest.poets.filter { it.nickname.contains(query.trim(), ignoreCase = true) }
val poets = remember(query, manifest, sort) {
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
}
}
LazyVerticalGrid(
@@ -73,6 +102,20 @@ fun PoetsScreen(onPoet: (String) -> Unit) {
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),
) {
PoetSort.entries.forEach { option ->
FilterChip(
selected = sort == option,
onClick = { settings.update { it.copy(poetSort = option) } },
label = { Text(stringResource(option.label)) },
)
}
}
}
item(span = { GridItemSpan(maxLineSpan) }) {
OutlinedTextField(
value = query,
@@ -1,11 +1,12 @@
package com.ganjoor.android.ui
import android.app.Activity
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
@@ -24,9 +25,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.ganjoor.android.R
import com.ganjoor.android.data.Ganjoor
import com.ganjoor.android.ui.theme.readingStyle
/**
@@ -52,6 +55,7 @@ fun ReadingSettingsAction() {
fun ReadingSettingsSheet(onDismiss: () -> Unit) {
val settings = LocalSettings.current
val prefs = settings.value
val context = LocalContext.current
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
@@ -66,25 +70,18 @@ fun ReadingSettingsSheet(onDismiss: () -> Unit) {
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Label(R.string.theme)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ThemeMode.entries.forEach { mode ->
FilterChip(
selected = prefs.theme == mode,
onClick = { settings.update { it.copy(theme = mode) } },
label = { Text(stringResource(mode.label)) },
)
}
Chips(ThemeMode.entries, prefs.theme, { stringResource(it.label) }) { mode ->
settings.update { it.copy(theme = mode) }
}
Label(R.string.font)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ReadingFont.entries.forEach { font ->
FilterChip(
selected = prefs.font == font,
onClick = { settings.update { it.copy(font = font) } },
label = { Text(stringResource(font.label)) },
)
}
Chips(ReadingFont.entries, prefs.font, { stringResource(it.label) }) { font ->
settings.update { it.copy(font = font) }
}
Label(R.string.weight)
Chips(ReadingWeight.entries, prefs.fontWeight, { stringResource(it.label) }) { weight ->
settings.update { it.copy(fontWeight = weight) }
}
Label(R.string.text_size)
@@ -94,34 +91,78 @@ fun ReadingSettingsSheet(onDismiss: () -> Unit) {
valueRange = 14f..40f,
steps = 12,
)
// Live preview, so the size and font choice can be judged before closing the sheet.
// Live preview, so size, weight and font can be judged before closing the sheet.
Text(
text = stringResource(R.string.font_preview),
style = readingStyle(prefs.font, prefs.fontSize),
style = readingStyle(prefs.font, prefs.fontSize, prefs.fontWeight.weight),
modifier = Modifier.fillMaxWidth(),
)
Row(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(stringResource(R.string.show_summaries))
Text(
text = stringResource(R.string.summary_ai_note),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Label(R.string.language)
Chips(Language.entries, prefs.language, { it.label }) { language ->
if (language != prefs.language) {
settings.update { it.copy(language = language) }
// Resources are picked in attachBaseContext, so the activity has to restart.
(context as? Activity)?.recreate()
}
Switch(
checked = prefs.showSummaries,
onCheckedChange = { on -> settings.update { it.copy(showSummaries = on) } },
)
}
Toggle(
title = R.string.offline_mode,
note = R.string.offline_mode_note,
checked = prefs.offline,
) { on ->
settings.update { it.copy(offline = on) }
Ganjoor.offline = on
}
Toggle(
title = R.string.show_summaries,
note = R.string.summary_ai_note,
checked = prefs.showSummaries,
) { on ->
settings.update { it.copy(showSummaries = on) }
}
}
}
}
@Composable
private fun <T> Chips(
options: List<T>,
selected: T,
label: @Composable (T) -> String,
onSelect: (T) -> Unit,
) {
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
options.forEach { option ->
FilterChip(
selected = option == selected,
onClick = { onSelect(option) },
label = { Text(label(option)) },
)
}
}
}
@Composable
private fun Toggle(title: Int, note: Int, checked: Boolean, onChange: (Boolean) -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(stringResource(title))
Text(
text = stringResource(note),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(checked = checked, onCheckedChange = onChange)
}
}
@Composable
private fun Label(resId: Int) {
Text(
@@ -6,9 +6,12 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.text.font.FontWeight
import androidx.core.content.edit
import com.ganjoor.android.R
const val PREFS_NAME = "ganjoor"
enum class ThemeMode(@StringRes val label: Int) {
System(R.string.theme_system),
Light(R.string.theme_light),
@@ -18,43 +21,95 @@ enum class ThemeMode(@StringRes val label: Int) {
}
enum class ReadingFont(@StringRes val label: Int) {
Nastaliq(R.string.font_nastaliq),
Naskh(R.string.font_naskh),
Nastaliq(R.string.font_nastaliq),
}
/** How heavy the poem text is drawn. Thin strokes wash out on a lit screen. */
enum class ReadingWeight(@StringRes val label: Int, val weight: FontWeight) {
Regular(R.string.weight_regular, FontWeight.Normal),
Medium(R.string.weight_medium, FontWeight.Medium),
SemiBold(R.string.weight_semibold, FontWeight.SemiBold),
Bold(R.string.weight_bold, FontWeight.Bold),
}
/** Each language is named in its own script, so the labels are the same whatever the UI locale. */
enum class Language(val tag: String, val label: String) {
Fa("fa", "فارسی"),
Ur("ur", "اردو"),
En("en", "English"),
}
/** How the poet grid is ordered. */
enum class PoetSort(@StringRes val label: Int) {
/** The data set's own order, which is Ganjoor's — best known poets first. */
Default(R.string.sort_default),
Name(R.string.sort_name),
}
data class Prefs(
val theme: ThemeMode = ThemeMode.System,
val font: ReadingFont = ReadingFont.Nastaliq,
val font: ReadingFont = ReadingFont.Naskh,
val fontSize: Float = 22f,
val fontWeight: ReadingWeight = ReadingWeight.Regular,
val showSummaries: Boolean = false,
val language: Language = Language.Fa,
val offline: Boolean = false,
val poetSort: PoetSort = PoetSort.Default,
)
/** Reading preferences, kept in SharedPreferences and read once at startup. */
class Settings(context: Context) {
private val prefs = context.getSharedPreferences("ganjoor", Context.MODE_PRIVATE)
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
var value by mutableStateOf(
Prefs(
theme = enumOrDefault(prefs.getString("theme", null), ThemeMode.System),
font = enumOrDefault(prefs.getString("font", null), ReadingFont.Nastaliq),
font = enumOrDefault(prefs.getString("font", null), ReadingFont.Naskh),
fontSize = prefs.getFloat("fontSize", 22f),
fontWeight = enumOrDefault(prefs.getString("fontWeight", null), ReadingWeight.Regular),
showSummaries = prefs.getBoolean("showSummaries", false),
language = languageOrDefault(prefs.getString(KEY_LANGUAGE, null)),
offline = prefs.getBoolean("offline", false),
poetSort = enumOrDefault(prefs.getString("poetSort", null), PoetSort.Default),
)
)
private set
fun update(block: (Prefs) -> Prefs) {
value = block(value).also { p ->
prefs.edit {
// commit, not apply: these are a few hundred bytes written when someone taps a
// setting, and an async write can be lost if the process is killed before it lands.
prefs.edit(commit = true) {
putString("theme", p.theme.name)
putString("font", p.font.name)
putFloat("fontSize", p.fontSize)
putString("fontWeight", p.fontWeight.name)
putBoolean("showSummaries", p.showSummaries)
putString(KEY_LANGUAGE, p.language.tag)
putBoolean("offline", p.offline)
putString("poetSort", p.poetSort.name)
}
}
}
companion object {
const val KEY_LANGUAGE = "language"
/**
* Read straight from prefs, for [com.ganjoor.android.MainActivity.attachBaseContext],
* which runs long before any of the Compose world exists.
*/
fun language(context: Context): Language = languageOrDefault(
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getString(KEY_LANGUAGE, null)
)
}
}
private fun languageOrDefault(tag: String?): Language =
Language.entries.firstOrNull { it.tag == tag } ?: Language.Fa
private inline fun <reified T : Enum<T>> enumOrDefault(name: String?, fallback: T): T =
runCatching { enumValueOf<T>(name!!) }.getOrDefault(fallback)
@@ -10,6 +10,7 @@ import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
import com.ganjoor.android.ui.Language
import com.ganjoor.android.ui.ThemeMode
// Persian tilework: turquoise with saffron accents.
@@ -116,7 +117,7 @@ private val SepiaDarkScheme = darkColorScheme(
)
@Composable
fun GanjoorTheme(mode: ThemeMode, content: @Composable () -> Unit) {
fun GanjoorTheme(mode: ThemeMode, language: Language, content: @Composable () -> Unit) {
val dark = when (mode) {
ThemeMode.System -> isSystemInDarkTheme()
ThemeMode.Light, ThemeMode.Sepia -> false
@@ -139,5 +140,10 @@ fun GanjoorTheme(mode: ThemeMode, content: @Composable () -> Unit) {
}
}
MaterialTheme(colorScheme = scheme, typography = GanjoorTypography, content = content)
MaterialTheme(
colorScheme = scheme,
// Latin interface text gets a Latin reading serif; the RTL interfaces stay on naskh.
typography = if (language == Language.En) LibronTypography else NaskhTypography,
content = content,
)
}
@@ -1,53 +1,85 @@
package com.ganjoor.android.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.text.PlatformTextStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontVariation
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.sp
import com.ganjoor.android.R
import com.ganjoor.android.ui.ReadingFont
/**
* Both are variable fonts shipped at their default weight; Android synthesises bold. Variable
* axes would need API 26+ (minSdk is 24), and a poetry reader has no use for weight animation.
* Both files are variable fonts with a `wght` axis, registered at four weights so a reader can
* thicken the text — thin naskh strokes wash out on a lit screen, especially in the dark themes.
*
* Real axis interpolation needs API 26+; on 24 and 25 the variation settings are ignored and
* Android falls back to synthesising the heavier weights, which still darkens the text.
*/
val Naskh = FontFamily(Font(R.font.noto_naskh_arabic))
val Nastaliq = FontFamily(Font(R.font.noto_nastaliq_urdu))
// Noto Naskh Arabic covers Persian, Urdu and Arabic plus Latin, so it can carry the whole UI.
private val default = Typography()
val GanjoorTypography = Typography(
displayLarge = default.displayLarge.copy(fontFamily = Naskh),
displayMedium = default.displayMedium.copy(fontFamily = Naskh),
displaySmall = default.displaySmall.copy(fontFamily = Naskh),
headlineLarge = default.headlineLarge.copy(fontFamily = Naskh),
headlineMedium = default.headlineMedium.copy(fontFamily = Naskh),
headlineSmall = default.headlineSmall.copy(fontFamily = Naskh),
titleLarge = default.titleLarge.copy(fontFamily = Naskh),
titleMedium = default.titleMedium.copy(fontFamily = Naskh),
titleSmall = default.titleSmall.copy(fontFamily = Naskh),
bodyLarge = default.bodyLarge.copy(fontFamily = Naskh),
bodyMedium = default.bodyMedium.copy(fontFamily = Naskh),
bodySmall = default.bodySmall.copy(fontFamily = Naskh),
labelLarge = default.labelLarge.copy(fontFamily = Naskh),
labelMedium = default.labelMedium.copy(fontFamily = Naskh),
labelSmall = default.labelSmall.copy(fontFamily = Naskh),
@OptIn(ExperimentalTextApi::class)
private fun variable(resId: Int, weight: FontWeight) = Font(
resId = resId,
weight = weight,
variationSettings = FontVariation.Settings(weight, FontStyle.Normal),
)
private val weights =
listOf(FontWeight.Normal, FontWeight.Medium, FontWeight.SemiBold, FontWeight.Bold)
val Naskh = FontFamily(weights.map { variable(R.font.noto_naskh_arabic, it) })
val Nastaliq = FontFamily(weights.map { variable(R.font.noto_nastaliq_urdu, it) })
/**
* The English UI only. Libron is a reading serif (OFL, github.com/nicoverbruggen/libron) and
* reads far better in Latin than the Latin side of an Arabic font. It never touches the poems:
* those are always naskh or nastaliq, whatever language the interface is in.
*/
val Libron = FontFamily(
Font(R.font.libron_regular, FontWeight.Normal),
Font(R.font.libron_bold, FontWeight.Bold),
)
// Noto Naskh Arabic covers Persian, Urdu and Arabic plus Latin, so it carries the RTL interfaces.
private val default = Typography()
private fun typographyOf(family: FontFamily) = Typography(
displayLarge = default.displayLarge.copy(fontFamily = family),
displayMedium = default.displayMedium.copy(fontFamily = family),
displaySmall = default.displaySmall.copy(fontFamily = family),
headlineLarge = default.headlineLarge.copy(fontFamily = family),
headlineMedium = default.headlineMedium.copy(fontFamily = family),
headlineSmall = default.headlineSmall.copy(fontFamily = family),
titleLarge = default.titleLarge.copy(fontFamily = family),
titleMedium = default.titleMedium.copy(fontFamily = family),
titleSmall = default.titleSmall.copy(fontFamily = family),
bodyLarge = default.bodyLarge.copy(fontFamily = family),
bodyMedium = default.bodyMedium.copy(fontFamily = family),
bodySmall = default.bodySmall.copy(fontFamily = family),
labelLarge = default.labelLarge.copy(fontFamily = family),
labelMedium = default.labelMedium.copy(fontFamily = family),
labelSmall = default.labelSmall.copy(fontFamily = family),
)
val NaskhTypography = typographyOf(Naskh)
val LibronTypography = typographyOf(Libron)
/**
* Style for the poem text itself. Nastaliq stacks its glyphs diagonally and has very deep
* descenders, so it needs far more leading than naskh and the legacy font padding kept on,
* otherwise the swashes clip against the line above. Tune the two multipliers by eye if you
* swap either font file.
*/
fun readingStyle(font: ReadingFont, sizeSp: Float): TextStyle {
fun readingStyle(font: ReadingFont, sizeSp: Float, weight: FontWeight): TextStyle {
val size: TextUnit = sizeSp.sp
val leading = if (font == ReadingFont.Nastaliq) 2.4f else 1.8f
return TextStyle(
fontFamily = if (font == ReadingFont.Nastaliq) Nastaliq else Naskh,
fontWeight = weight,
fontSize = size,
lineHeight = size * leading,
platformStyle = PlatformTextStyle(includeFontPadding = true),
@@ -0,0 +1,9 @@
<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="M5,20h14v-2H5v2zM19,9h-4V3H9v6H5l7,7 7,-7z" />
</vector>
@@ -0,0 +1,9 @@
<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="M11.99,2C6.47,2 2,6.48 2,12s4.47,10 9.99,10C17.52,22 22,17.52 22,12S17.52,2 11.99,2zM18.92,8h-2.95c-0.32,-1.25 -0.78,-2.45 -1.38,-3.56 1.84,0.63 3.37,1.91 4.33,3.56zM12,4.04c0.83,1.2 1.48,2.53 1.91,3.96h-3.82c0.43,-1.43 1.08,-2.76 1.91,-3.96zM4.26,14C4.1,13.36 4,12.69 4,12s0.1,-1.36 0.26,-2h3.38c-0.08,0.66 -0.14,1.32 -0.14,2 0,0.68 0.06,1.34 0.14,2L4.26,14zM5.08,16h2.95c0.32,1.25 0.78,2.45 1.38,3.56 -1.84,-0.63 -3.37,-1.9 -4.33,-3.56zM8.03,8L5.08,8c0.96,-1.66 2.49,-2.93 4.33,-3.56C8.81,5.55 8.35,6.75 8.03,8zM12,19.96c-0.83,-1.2 -1.48,-2.53 -1.91,-3.96h3.82c-0.43,1.43 -1.08,2.76 -1.91,3.96zM14.34,14L9.66,14c-0.09,-0.66 -0.16,-1.32 -0.16,-2 0,-0.68 0.07,-1.35 0.16,-2h4.68c0.09,0.65 0.16,1.32 0.16,2 0,0.68 -0.07,1.34 -0.16,2zM14.59,19.56c0.6,-1.11 1.06,-2.31 1.38,-3.56h2.95c-0.96,1.65 -2.49,2.93 -4.33,3.56zM16.36,14c0.08,-0.66 0.14,-1.32 0.14,-2 0,-0.68 -0.06,-1.34 -0.14,-2h3.38c0.16,0.64 0.26,1.31 0.26,2s-0.1,1.36 -0.26,2h-3.38z" />
</vector>
@@ -1,170 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:fillColor="#00696E"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
@@ -1,30 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
android:fillColor="#F1E5CE"
android:fillType="evenOdd"
android:pathData="M54.00,20.00 L63.96,29.96 L78.04,29.96 L78.04,44.04 L88.00,54.00 L78.04,63.96 L78.04,78.04 L63.96,78.04 L54.00,88.00 L44.04,78.04 L29.96,78.04 L29.96,63.96 L20.00,54.00 L29.96,44.04 L29.96,29.96 L44.04,29.96 Z M54.00,39.00 L58.39,43.39 L64.61,43.39 L64.61,49.61 L69.00,54.00 L64.61,58.39 L64.61,64.61 L58.39,64.61 L54.00,69.00 L49.61,64.61 L43.39,64.61 L43.39,58.39 L39.00,54.00 L43.39,49.61 L43.39,43.39 L49.61,43.39 Z" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FF000000"
android:fillType="evenOdd"
android:pathData="M54.00,20.00 L63.96,29.96 L78.04,29.96 L78.04,44.04 L88.00,54.00 L78.04,63.96 L78.04,78.04 L63.96,78.04 L54.00,88.00 L44.04,78.04 L29.96,78.04 L29.96,63.96 L20.00,54.00 L29.96,44.04 L29.96,29.96 L44.04,29.96 Z M54.00,39.00 L58.39,43.39 L64.61,43.39 L64.61,49.61 L69.00,54.00 L64.61,58.39 L64.61,64.61 L58.39,64.61 L54.00,69.00 L49.61,64.61 L43.39,64.61 L43.39,58.39 L39.00,54.00 L43.39,49.61 L43.39,43.39 L49.61,43.39 Z" />
</vector>
Binary file not shown.
Binary file not shown.
@@ -2,5 +2,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>
@@ -2,5 +2,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 753 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

+51
View File
@@ -0,0 +1,51 @@
<resources>
<string name="search_poets">جست‌وجوی شاعران</string>
<string name="back">بازگشت</string>
<string name="retry">تلاش دوباره</string>
<string name="load_failed">بارگذاری نشد. اتصال اینترنت را بررسی کنید.</string>
<string name="load_failed_offline">این بخش ذخیره نشده است. حالت برون‌خط را خاموش کنید یا آن را دریافت کنید.</string>
<string name="reading_settings">تنظیمات خواندن</string>
<string name="theme">پوسته</string>
<string name="theme_system">سیستم</string>
<string name="theme_light">روشن</string>
<string name="theme_dark">تاریک</string>
<string name="theme_sepia">کاغذی</string>
<string name="theme_sepia_dark">کاغذی شب</string>
<string name="font">قلم</string>
<string name="font_naskh">نسخ</string>
<string name="font_nastaliq">نستعلیق</string>
<string name="text_size">اندازهٔ متن</string>
<string name="language">زبان</string>
<string name="show_summaries">نمایش خلاصه‌ها</string>
<string name="summary_ai_note">تولیدشده با هوش مصنوعی گنجور، نه سرودهٔ شاعر</string>
<string name="source">منبع: %s</string>
<string name="menu">منو</string>
<string name="bookmarks">نشان‌شده‌ها</string>
<string name="bookmark_add">افزودن به نشان‌ها</string>
<string name="bookmark_remove">حذف از نشان‌ها</string>
<string name="no_bookmarks">هنوز شعری را نشان نکرده‌اید.</string>
<string name="downloads">دریافت‌ها</string>
<string name="offline_mode">حالت برون‌خط</string>
<string name="offline_mode_note">فقط از شعرهای ذخیره‌شده بخوان</string>
<string name="download">دریافت</string>
<string name="downloading">در حال دریافت… %1$d از %2$d</string>
<string name="downloaded">ذخیره شد (%d شعر)</string>
<string name="delete_download">حذف از حافظه</string>
<string name="download_all">دریافت همهٔ شاعران</string>
<string name="download_all_warning">دریافت همهٔ ۱۳۵٬۰۰۰ شعر ساعت‌ها طول می‌کشد و صدها مگابایت جا می‌گیرد.</string>
<string name="cancel">انصراف</string>
<string name="storage_used">فضای مصرف‌شده: %s</string>
<string name="weight">ضخامت قلم</string>
<string name="weight_regular">معمولی</string>
<string name="weight_medium">متوسط</string>
<string name="weight_semibold">نیمه‌ضخیم</string>
<string name="weight_bold">ضخیم</string>
<string name="sort">ترتیب</string>
<string name="sort_default">ترتیب گنجور</string>
<string name="sort_name">الفبایی</string>
<string name="save_passage">ذخیرهٔ این بیت</string>
<string name="saved_passage">ذخیره شد</string>
<string name="copy">رونوشت</string>
<string name="copied">رونوشت شد</string>
<string name="download_selected">دریافت %d شاعر</string>
</resources>
+51
View File
@@ -0,0 +1,51 @@
<resources>
<string name="search_poets">شاعروں کی تلاش</string>
<string name="back">واپس</string>
<string name="retry">دوبارہ کوشش</string>
<string name="load_failed">لوڈ نہیں ہو سکا۔ اپنا انٹرنیٹ کنکشن دیکھیں۔</string>
<string name="load_failed_offline">یہ محفوظ نہیں ہے۔ آف لائن موڈ بند کریں یا پہلے ڈاؤن لوڈ کریں۔</string>
<string name="reading_settings">مطالعے کی ترتیبات</string>
<string name="theme">تھیم</string>
<string name="theme_system">سسٹم</string>
<string name="theme_light">روشن</string>
<string name="theme_dark">تاریک</string>
<string name="theme_sepia">کاغذی</string>
<string name="theme_sepia_dark">کاغذی رات</string>
<string name="font">فونٹ</string>
<string name="font_naskh">نسخ</string>
<string name="font_nastaliq">نستعلیق</string>
<string name="text_size">متن کا سائز</string>
<string name="language">زبان</string>
<string name="show_summaries">خلاصے دکھائیں</string>
<string name="summary_ai_note">گنجور کے مصنوعی ذہانت سے تیار کردہ، شاعر کا کلام نہیں</string>
<string name="source">ماخذ: %s</string>
<string name="menu">مینیو</string>
<string name="bookmarks">محفوظ کلام</string>
<string name="bookmark_add">محفوظ کریں</string>
<string name="bookmark_remove">محفوظ شدہ سے ہٹائیں</string>
<string name="no_bookmarks">ابھی کوئی کلام محفوظ نہیں کیا گیا۔</string>
<string name="downloads">ڈاؤن لوڈز</string>
<string name="offline_mode">آف لائن موڈ</string>
<string name="offline_mode_note">صرف محفوظ شدہ کلام پڑھیں</string>
<string name="download">ڈاؤن لوڈ</string>
<string name="downloading">ڈاؤن لوڈ جاری… %2$d میں سے %1$d</string>
<string name="downloaded">محفوظ ہو گیا (%d کلام)</string>
<string name="delete_download">آلے سے حذف کریں</string>
<string name="download_all">تمام شعرا ڈاؤن لوڈ کریں</string>
<string name="download_all_warning">تمام ۱۳۵٬۰۰۰ کلام ڈاؤن لوڈ کرنے میں کئی گھنٹے اور سینکڑوں میگابائٹ لگیں گے۔</string>
<string name="cancel">منسوخ</string>
<string name="storage_used">استعمال شدہ جگہ: %s</string>
<string name="weight">فونٹ کی موٹائی</string>
<string name="weight_regular">عام</string>
<string name="weight_medium">درمیانہ</string>
<string name="weight_semibold">نیم موٹا</string>
<string name="weight_bold">موٹا</string>
<string name="sort">ترتیب</string>
<string name="sort_default">گنجور کی ترتیب</string>
<string name="sort_name">حروفِ تہجی</string>
<string name="save_passage">یہ شعر محفوظ کریں</string>
<string name="saved_passage">محفوظ ہو گیا</string>
<string name="copy">نقل کریں</string>
<string name="copied">نقل ہو گیا</string>
<string name="download_selected">%d شعرا ڈاؤن لوڈ کریں</string>
</resources>
+36 -4
View File
@@ -1,9 +1,11 @@
<resources>
<string name="app_name">Ganjoor</string>
<!-- The app is called گنجور in every language. -->
<string name="app_name">گنجور</string>
<string name="search_poets">Search poets</string>
<string name="back">Back</string>
<string name="retry">Retry</string>
<string name="load_failed">Could not load this page. Check your connection and try again.</string>
<string name="load_failed_offline">This isn\'t downloaded. Turn off offline mode, or download it first.</string>
<string name="reading_settings">Reading settings</string>
<string name="theme">Theme</string>
<string name="theme_system">System</string>
@@ -14,11 +16,41 @@
<string name="font">Font</string>
<string name="font_naskh">Naskh</string>
<string name="font_nastaliq">Nastaliq</string>
<string name="font_preview">الا یا ایها الساقی ادر کأسا و ناولها</string>
<string name="text_size">Text size</string>
<string name="language">Language</string>
<string name="show_summaries">Show summaries</string>
<string name="summary_ai_note">AI-generated by Ganjoor, not by the poet</string>
<string name="previous_poem">‹ %s</string>
<string name="next_poem">%s ›</string>
<string name="source">Source: %s</string>
<string name="menu">Menu</string>
<string name="bookmarks">Bookmarks</string>
<string name="bookmark_add">Bookmark</string>
<string name="bookmark_remove">Remove bookmark</string>
<string name="no_bookmarks">Nothing bookmarked yet.</string>
<string name="downloads">Downloads</string>
<string name="offline_mode">Offline mode</string>
<string name="offline_mode_note">Read only what\'s been downloaded</string>
<string name="download">Download</string>
<string name="downloading">Downloading… %1$d of %2$d</string>
<string name="downloaded">Saved (%d poems)</string>
<string name="delete_download">Delete from device</string>
<string name="download_all">Download every poet</string>
<string name="download_all_warning">Downloading all 135,000 poems takes hours and hundreds of megabytes.</string>
<string name="cancel">Cancel</string>
<string name="storage_used">Storage used: %s</string>
<string name="weight">Font weight</string>
<string name="weight_regular">Regular</string>
<string name="weight_medium">Medium</string>
<string name="weight_semibold">Semibold</string>
<string name="weight_bold">Bold</string>
<string name="sort">Sort</string>
<string name="sort_default">Ganjoor\'s order</string>
<string name="sort_name">By name</string>
<string name="save_passage">Save this passage</string>
<string name="saved_passage">Saved</string>
<string name="copy">Copy</string>
<string name="copied">Copied</string>
<string name="font_preview">الا یا ایها الساقی ادر کأسا و ناولها</string>
<string name="next_poem">%s ›</string>
<string name="previous_poem">‹ %s</string>
<string name="download_selected">Download %d poets</string>
</resources>
@@ -0,0 +1 @@
First release.
@@ -0,0 +1,17 @@
A reader for Ganjoor, the open archive of Persian poetry — 240 poets and around 135,000 poems,
from Rudaki and Ferdowsi through Hafez, Saadi and Rumi to Parvin Etesami.
Built for reading rather than browsing:
* Poems are set as couplets, the way they are meant to be read, with prose sections laid out as prose.
* Two bundled fonts, Noto Naskh Arabic and Noto Nastaliq Urdu, switchable mid-poem, with four weights so the text can be thickened for a lit screen.
* Five themes: system, light, dark, sepia and sepia night.
* Adjustable text size.
* The whole interface reads and navigates right to left, in Persian, Urdu or English.
* Bookmark anything, kept on the device.
* Download a poet, or every poet, and read with no connection at all.
The poems come from a static, git-tracked export of Ganjoor's content served over a CDN. There is
no account, no tracking and no server of the app's own.
Not affiliated with or endorsed by Ganjoor.
@@ -0,0 +1 @@
Persian poetry from Ganjoor, in naskh or nastaliq, online or offline.
@@ -0,0 +1 @@
Ganjoor
@@ -0,0 +1 @@
نخستین نسخه.
@@ -0,0 +1,17 @@
خوانندهٔ گنجور، گنجینهٔ آزاد شعر پارسی — ۲۴۰ شاعر و نزدیک به ۱۳۵٬۰۰۰ شعر، از رودکی و فردوسی تا
حافظ و سعدی و مولانا و پروین اعتصامی.
ساخته شده برای خواندن، نه فقط گشتن:
* شعرها به صورت بیت‌بیت نمایش داده می‌شوند و بخش‌های منثور همچون نثر.
* دو قلم همراه برنامه، نسخ و نستعلیق، با امکان جابه‌جایی در میانهٔ شعر و چهار ضخامت.
* پنج پوسته: سیستم، روشن، تاریک، کاغذی و کاغذی شب.
* اندازهٔ متن قابل تنظیم.
* همهٔ رابط کاربری راست‌به‌چپ، به فارسی، اردو یا انگلیسی.
* نشان‌کردن شعرها روی خود دستگاه.
* دریافت یک شاعر یا همهٔ شاعران و خواندن بدون اینترنت.
داده‌ها از یک نسخهٔ ایستا و نسخه‌پذیر از محتوای گنجور روی CDN خوانده می‌شود. نه حسابی، نه ردیابی،
و نه سروری از آنِ خود برنامه.
این برنامه وابسته به گنجور نیست.
@@ -0,0 +1 @@
شعر فارسی از گنجور، به خط نسخ یا نستعلیق، برخط یا برون‌خط.
+1
View File
@@ -0,0 +1 @@
گنجور
@@ -0,0 +1 @@
پہلی ریلیز۔
@@ -0,0 +1,17 @@
گنجور کا ریڈر — فارسی شاعری کا آزاد ذخیرہ، ۲۴۰ شعرا اور تقریباً ۱۳۵٬۰۰۰ کلام، رودکی اور فردوسی سے
لے کر حافظ، سعدی، مولانا رومی اور پروین اعتصامی تک۔
پڑھنے کے لیے بنایا گیا ہے:
* کلام شعر بہ شعر دکھایا جاتا ہے، اور نثر نثر کی طرح۔
* دو شامل فونٹ، نسخ اور نستعلیق، پڑھتے ہوئے تبدیل کیے جا سکتے ہیں، چار موٹائیوں کے ساتھ۔
* پانچ تھیم: سسٹم، روشن، تاریک، کاغذی اور کاغذی رات۔
* متن کا سائز اپنی مرضی کے مطابق۔
* پورا انٹرفیس دائیں سے بائیں — فارسی، اردو یا انگریزی میں۔
* کلام کو آلے پر محفوظ کریں۔
* ایک شاعر یا تمام شعرا ڈاؤن لوڈ کریں اور بغیر انٹرنیٹ کے پڑھیں۔
تمام کلام گنجور کے مواد کی ایک جامد برآمد سے آتا ہے جو CDN پر رکھی گئی ہے۔ نہ کوئی اکاؤنٹ، نہ
ٹریکنگ، نہ ایپ کا اپنا کوئی سرور۔
یہ ایپ گنجور سے وابستہ نہیں ہے۔
@@ -0,0 +1 @@
گنجور سے فارسی کلام، نسخ یا نستعلیق میں، آن لائن یا آف لائن۔
+1
View File
@@ -0,0 +1 @@
گنجور
+94
View File
@@ -0,0 +1,94 @@
Newsreader (c) 2020 The Newsreader Project Authors (http://github.com/productiontype/Newsreader)
Readerly (c) 2026 Nico Verbruggen (https://github.com/nicoverbruggen/readerly)
Libron (c) 2026 Nico Verbruggen (https://github.com/nicoverbruggen/libron)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.