Add offline reading, bookmarks, three UI languages and downloads

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

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

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

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

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

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

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

No files matched your search

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

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 753 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

+51
View File
@@ -0,0 +1,51 @@
<resources>
<string name="search_poets">جست‌وجوی شاعران</string>
<string name="back">بازگشت</string>
<string name="retry">تلاش دوباره</string>
<string name="load_failed">بارگذاری نشد. اتصال اینترنت را بررسی کنید.</string>
<string name="load_failed_offline">این بخش ذخیره نشده است. حالت برون‌خط را خاموش کنید یا آن را دریافت کنید.</string>
<string name="reading_settings">تنظیمات خواندن</string>
<string name="theme">پوسته</string>
<string name="theme_system">سیستم</string>
<string name="theme_light">روشن</string>
<string name="theme_dark">تاریک</string>
<string name="theme_sepia">کاغذی</string>
<string name="theme_sepia_dark">کاغذی شب</string>
<string name="font">قلم</string>
<string name="font_naskh">نسخ</string>
<string name="font_nastaliq">نستعلیق</string>
<string name="text_size">اندازهٔ متن</string>
<string name="language">زبان</string>
<string name="show_summaries">نمایش خلاصه‌ها</string>
<string name="summary_ai_note">تولیدشده با هوش مصنوعی گنجور، نه سرودهٔ شاعر</string>
<string name="source">منبع: %s</string>
<string name="menu">منو</string>
<string name="bookmarks">نشان‌شده‌ها</string>
<string name="bookmark_add">افزودن به نشان‌ها</string>
<string name="bookmark_remove">حذف از نشان‌ها</string>
<string name="no_bookmarks">هنوز شعری را نشان نکرده‌اید.</string>
<string name="downloads">دریافت‌ها</string>
<string name="offline_mode">حالت برون‌خط</string>
<string name="offline_mode_note">فقط از شعرهای ذخیره‌شده بخوان</string>
<string name="download">دریافت</string>
<string name="downloading">در حال دریافت… %1$d از %2$d</string>
<string name="downloaded">ذخیره شد (%d شعر)</string>
<string name="delete_download">حذف از حافظه</string>
<string name="download_all">دریافت همهٔ شاعران</string>
<string name="download_all_warning">دریافت همهٔ ۱۳۵٬۰۰۰ شعر ساعت‌ها طول می‌کشد و صدها مگابایت جا می‌گیرد.</string>
<string name="cancel">انصراف</string>
<string name="storage_used">فضای مصرف‌شده: %s</string>
<string name="weight">ضخامت قلم</string>
<string name="weight_regular">معمولی</string>
<string name="weight_medium">متوسط</string>
<string name="weight_semibold">نیمه‌ضخیم</string>
<string name="weight_bold">ضخیم</string>
<string name="sort">ترتیب</string>
<string name="sort_default">ترتیب گنجور</string>
<string name="sort_name">الفبایی</string>
<string name="save_passage">ذخیرهٔ این بیت</string>
<string name="saved_passage">ذخیره شد</string>
<string name="copy">رونوشت</string>
<string name="copied">رونوشت شد</string>
<string name="download_selected">دریافت %d شاعر</string>
</resources>
+51
View File
@@ -0,0 +1,51 @@
<resources>
<string name="search_poets">شاعروں کی تلاش</string>
<string name="back">واپس</string>
<string name="retry">دوبارہ کوشش</string>
<string name="load_failed">لوڈ نہیں ہو سکا۔ اپنا انٹرنیٹ کنکشن دیکھیں۔</string>
<string name="load_failed_offline">یہ محفوظ نہیں ہے۔ آف لائن موڈ بند کریں یا پہلے ڈاؤن لوڈ کریں۔</string>
<string name="reading_settings">مطالعے کی ترتیبات</string>
<string name="theme">تھیم</string>
<string name="theme_system">سسٹم</string>
<string name="theme_light">روشن</string>
<string name="theme_dark">تاریک</string>
<string name="theme_sepia">کاغذی</string>
<string name="theme_sepia_dark">کاغذی رات</string>
<string name="font">فونٹ</string>
<string name="font_naskh">نسخ</string>
<string name="font_nastaliq">نستعلیق</string>
<string name="text_size">متن کا سائز</string>
<string name="language">زبان</string>
<string name="show_summaries">خلاصے دکھائیں</string>
<string name="summary_ai_note">گنجور کے مصنوعی ذہانت سے تیار کردہ، شاعر کا کلام نہیں</string>
<string name="source">ماخذ: %s</string>
<string name="menu">مینیو</string>
<string name="bookmarks">محفوظ کلام</string>
<string name="bookmark_add">محفوظ کریں</string>
<string name="bookmark_remove">محفوظ شدہ سے ہٹائیں</string>
<string name="no_bookmarks">ابھی کوئی کلام محفوظ نہیں کیا گیا۔</string>
<string name="downloads">ڈاؤن لوڈز</string>
<string name="offline_mode">آف لائن موڈ</string>
<string name="offline_mode_note">صرف محفوظ شدہ کلام پڑھیں</string>
<string name="download">ڈاؤن لوڈ</string>
<string name="downloading">ڈاؤن لوڈ جاری… %2$d میں سے %1$d</string>
<string name="downloaded">محفوظ ہو گیا (%d کلام)</string>
<string name="delete_download">آلے سے حذف کریں</string>
<string name="download_all">تمام شعرا ڈاؤن لوڈ کریں</string>
<string name="download_all_warning">تمام ۱۳۵٬۰۰۰ کلام ڈاؤن لوڈ کرنے میں کئی گھنٹے اور سینکڑوں میگابائٹ لگیں گے۔</string>
<string name="cancel">منسوخ</string>
<string name="storage_used">استعمال شدہ جگہ: %s</string>
<string name="weight">فونٹ کی موٹائی</string>
<string name="weight_regular">عام</string>
<string name="weight_medium">درمیانہ</string>
<string name="weight_semibold">نیم موٹا</string>
<string name="weight_bold">موٹا</string>
<string name="sort">ترتیب</string>
<string name="sort_default">گنجور کی ترتیب</string>
<string name="sort_name">حروفِ تہجی</string>
<string name="save_passage">یہ شعر محفوظ کریں</string>
<string name="saved_passage">محفوظ ہو گیا</string>
<string name="copy">نقل کریں</string>
<string name="copied">نقل ہو گیا</string>
<string name="download_selected">%d شعرا ڈاؤن لوڈ کریں</string>
</resources>
+36 -4
View File
@@ -1,9 +1,11 @@
<resources>
<string name="app_name">Ganjoor</string>
<!-- The app is called گنجور in every language. -->
<string name="app_name">گنجور</string>
<string name="search_poets">Search poets</string>
<string name="back">Back</string>
<string name="retry">Retry</string>
<string name="load_failed">Could not load this page. Check your connection and try again.</string>
<string name="load_failed_offline">This isn\'t downloaded. Turn off offline mode, or download it first.</string>
<string name="reading_settings">Reading settings</string>
<string name="theme">Theme</string>
<string name="theme_system">System</string>
@@ -14,11 +16,41 @@
<string name="font">Font</string>
<string name="font_naskh">Naskh</string>
<string name="font_nastaliq">Nastaliq</string>
<string name="font_preview">الا یا ایها الساقی ادر کأسا و ناولها</string>
<string name="text_size">Text size</string>
<string name="language">Language</string>
<string name="show_summaries">Show summaries</string>
<string name="summary_ai_note">AI-generated by Ganjoor, not by the poet</string>
<string name="previous_poem">‹ %s</string>
<string name="next_poem">%s ›</string>
<string name="source">Source: %s</string>
<string name="menu">Menu</string>
<string name="bookmarks">Bookmarks</string>
<string name="bookmark_add">Bookmark</string>
<string name="bookmark_remove">Remove bookmark</string>
<string name="no_bookmarks">Nothing bookmarked yet.</string>
<string name="downloads">Downloads</string>
<string name="offline_mode">Offline mode</string>
<string name="offline_mode_note">Read only what\'s been downloaded</string>
<string name="download">Download</string>
<string name="downloading">Downloading… %1$d of %2$d</string>
<string name="downloaded">Saved (%d poems)</string>
<string name="delete_download">Delete from device</string>
<string name="download_all">Download every poet</string>
<string name="download_all_warning">Downloading all 135,000 poems takes hours and hundreds of megabytes.</string>
<string name="cancel">Cancel</string>
<string name="storage_used">Storage used: %s</string>
<string name="weight">Font weight</string>
<string name="weight_regular">Regular</string>
<string name="weight_medium">Medium</string>
<string name="weight_semibold">Semibold</string>
<string name="weight_bold">Bold</string>
<string name="sort">Sort</string>
<string name="sort_default">Ganjoor\'s order</string>
<string name="sort_name">By name</string>
<string name="save_passage">Save this passage</string>
<string name="saved_passage">Saved</string>
<string name="copy">Copy</string>
<string name="copied">Copied</string>
<string name="font_preview">الا یا ایها الساقی ادر کأسا و ناولها</string>
<string name="next_poem">%s ›</string>
<string name="previous_poem">‹ %s</string>
<string name="download_selected">Download %d poets</string>
</resources>