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
@@ -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>
|
||||