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>
No files matched your search
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 753 B |
|
Before Width: | Height: | Size: 982 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 7.6 KiB |
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 @@
|
||||
شعر فارسی از گنجور، به خط نسخ یا نستعلیق، برخط یا برونخط.
|
||||
@@ -0,0 +1 @@
|
||||
گنجور
|
||||
@@ -0,0 +1 @@
|
||||
پہلی ریلیز۔
|
||||
@@ -0,0 +1,17 @@
|
||||
گنجور کا ریڈر — فارسی شاعری کا آزاد ذخیرہ، ۲۴۰ شعرا اور تقریباً ۱۳۵٬۰۰۰ کلام، رودکی اور فردوسی سے
|
||||
لے کر حافظ، سعدی، مولانا رومی اور پروین اعتصامی تک۔
|
||||
|
||||
پڑھنے کے لیے بنایا گیا ہے:
|
||||
|
||||
* کلام شعر بہ شعر دکھایا جاتا ہے، اور نثر نثر کی طرح۔
|
||||
* دو شامل فونٹ، نسخ اور نستعلیق، پڑھتے ہوئے تبدیل کیے جا سکتے ہیں، چار موٹائیوں کے ساتھ۔
|
||||
* پانچ تھیم: سسٹم، روشن، تاریک، کاغذی اور کاغذی رات۔
|
||||
* متن کا سائز اپنی مرضی کے مطابق۔
|
||||
* پورا انٹرفیس دائیں سے بائیں — فارسی، اردو یا انگریزی میں۔
|
||||
* کلام کو آلے پر محفوظ کریں۔
|
||||
* ایک شاعر یا تمام شعرا ڈاؤن لوڈ کریں اور بغیر انٹرنیٹ کے پڑھیں۔
|
||||
|
||||
تمام کلام گنجور کے مواد کی ایک جامد برآمد سے آتا ہے جو CDN پر رکھی گئی ہے۔ نہ کوئی اکاؤنٹ، نہ
|
||||
ٹریکنگ، نہ ایپ کا اپنا کوئی سرور۔
|
||||
|
||||
یہ ایپ گنجور سے وابستہ نہیں ہے۔
|
||||
@@ -0,0 +1 @@
|
||||
گنجور سے فارسی کلام، نسخ یا نستعلیق میں، آن لائن یا آف لائن۔
|
||||
@@ -0,0 +1 @@
|
||||
گنجور
|
||||
@@ -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.
|
||||