Build the Ganjoor reader on the static ganjoor-data set

Poets grid, category browsing and a poem reader backed by
github.com/anas-rashid/ganjoor-data over jsDelivr — no backend, addressed by
Ganjoor URLs rather than numeric ids, with a 64 MB OkHttp disk cache so
anything already read stays readable offline.

Reading: couplets stack with the first hemistich at the start of the line and
the second at the end; prose sections fill the column. Bundled Noto Naskh
Arabic and Noto Nastaliq Urdu, switchable while reading, with the extra
leading nastaliq needs. Five themes (system, light, dark, sepia, sepia night)
and a text size slider, all persisted. Whole app lays out right-to-left.

Verified on an API 36 emulator through poets -> category -> poem in both
sepia schemes and both fonts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Anas Rashid 2026-10-03 22:46:09 +02:00
parent 46ba109207
commit d9915eac6c
21 changed files with 1374 additions and 138 deletions

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Anas Rashid
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

96
README.md Normal file
View File

@ -0,0 +1,96 @@
# Ganjoor for Android
An Android reader for [Ganjoor](https://ganjoor.net), the open archive of Persian poetry —
240 poets and ~135,000 poems, laid out for comfortable long-form reading in Persian, Urdu
and Arabic script.
Jetpack Compose, Material 3, `minSdk 24`.
## What's here
**Reading.** Poems render as couplets: the two hemistichs of each line stack on a phone, the
first aligned to the start of the line and the second to the end, the way Ganjoor itself reads.
Prose sections (Golestan, for example) fill the column instead. The whole app lays out
right-to-left.
**Fonts.** Two bundled Noto families, switchable while reading:
| Font | File | Note |
|---|---|---|
| Naskh | `res/font/noto_naskh_arabic.ttf` | Covers Persian, Urdu, Arabic and Latin — also carries the UI |
| Nastaliq | `res/font/noto_nastaliq_urdu.ttf` | Traditional hanging script, needs ~2.4× leading (see `readingStyle`) |
Both are variable fonts shipped at their default weight; Android synthesises bold, since
variable axes would need API 26+. Licences are in [`licenses/`](licenses/) (SIL OFL 1.1).
**Themes.** Five modes, persisted across launches: System, Light, Dark, Sepia and Sepia night.
The two sepia schemes are warm paper tones for long sessions — the dark one has no blue cast.
**Text size.** A slider from 14 to 40 sp with a live preview in the settings sheet.
**Summaries.** Ganjoor publishes AI-generated summaries for some poems and couplets. They are
off by default and labelled as AI-generated wherever they appear.
## Where the poems come from
There is no backend. Every "endpoint" is a JSON file in
[`anas-rashid/ganjoor-data`](https://github.com/anas-rashid/ganjoor-data), served over
jsDelivr's CDN, and addressed by the poem's own Ganjoor URL:
```
/hafez/ghazal/sh1 -> poets/hafez/ghazal/sh1.json
/hafez/ghazal -> poets/hafez/ghazal/_cat.json
```
Because paths are URLs, the app never touches the numeric id indexes. Responses land in a 64 MB
OkHttp disk cache, so anything already read stays readable offline.
See [`data/Ganjoor.kt`](app/src/main/java/com/ganjoor/android/data/Ganjoor.kt) — the client is
about thirty lines.
## Build
```sh
./gradlew :app:assembleDebug # APK
./gradlew :app:testDebugUnitTest # couplet grouping tests
```
## Layout
```
data/Ganjoor.kt models, couplet grouping, the static-file client
ui/GanjoorApp.kt nav graph; routes carry Ganjoor URLs
ui/PoetsScreen.kt poet grid with name filter
ui/CategoryScreen.kt collections and poem lists
ui/PoemScreen.kt the reader
ui/ReadingSettings.kt theme / font / size sheet
ui/Settings.kt preferences
ui/Load.kt fetch + loading + retry
ui/theme/ colour schemes and typography
```
Single module, no DI framework, no ViewModels yet — screens fetch through `Load` and lean on the
HTTP cache. `ponytail:` comments mark the deliberate shortcuts and what would replace them.
## Not built yet
- **Search.** The data set has no search index; this needs either a client-side index or
`api.ganjoor.net`'s `/api/ganjoor/poems/search`.
- **Bookmarks and reading position.** Nothing is stored locally beyond preferences.
- **Recitations.** Ganjoor has audio for many poems; it isn't in this data set.
- **Offline download.** The HTTP cache covers what you've read, not a whole divan on demand.
## Licensing
The app code is MIT (see [`LICENSE`](LICENSE)). Two things worth knowing about what it builds on:
- **The poetry** is classical Persian verse, long out of copyright.
- **The data set** ([`ganjoor/ganjoor-data`](https://github.com/ganjoor/ganjoor-data)) carries no
licence file. Worth asking upstream to add an explicit one.
- **[GanjoorService](https://github.com/ganjoor/GanjoorService)**, Ganjoor's own backend and site,
is GPL-3.0. This app uses none of its code — only data over HTTPS — so it is not a derivative
work of it.
Fonts are SIL OFL 1.1, which permits bundling in an application.
Not affiliated with or endorsed by Ganjoor.

View File

@ -1,6 +1,7 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
}
android {
@ -14,7 +15,7 @@ android {
minSdk = 24
targetSdk = 37
versionCode = 1
versionName = "1.0"
versionName = "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@ -40,11 +41,17 @@ dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.icons.core)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.navigation.compose)
implementation(libs.okhttp)
implementation(libs.kotlinx.serialization.json)
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
testImplementation(libs.junit)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
@ -52,4 +59,4 @@ dependencies {
androidTestImplementation(libs.androidx.junit)
debugImplementation(libs.androidx.compose.ui.test.manifest)
debugImplementation(libs.androidx.compose.ui.tooling)
}
}

View File

@ -2,6 +2,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"

View File

@ -4,44 +4,32 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.ganjoor.android.ui.theme.GanjoorappandroidTheme
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.Ganjoor
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
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Ganjoor.init(applicationContext)
enableEdgeToEdge()
setContent {
GanjoorappandroidTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Greeting(
name = "Android",
modifier = Modifier.padding(innerPadding)
)
val settings = remember { Settings(applicationContext) }
CompositionLocalProvider(
LocalSettings provides settings,
// The content is Persian/Urdu/Arabic throughout, so the whole app is RTL.
LocalLayoutDirection provides LayoutDirection.Rtl,
) {
GanjoorTheme(settings.value.theme) {
GanjoorApp()
}
}
}
}
}
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Text(
text = "Hello $name!",
modifier = modifier
)
}
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
GanjoorappandroidTheme {
Greeting("Android")
}
}

View File

@ -0,0 +1,173 @@
package com.ganjoor.android.data
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNamingStrategy
import okhttp3.Cache
import okhttp3.CacheControl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.io.IOException
/**
* Ganjoor's poetry content as a static JSON "API": every endpoint below is a file in
* github.com/anas-rashid/ganjoor-data served over jsDelivr's CDN. See that repo's API.md.
*
* Paths come straight from Ganjoor URLs, so nothing here needs the numeric id indexes:
* /hafez/ghazal/sh1 -> poets/hafez/ghazal/sh1.json
*/
private const val DATA_BASE = "https://cdn.jsdelivr.net/gh/anas-rashid/ganjoor-data@main/"
// The data files use PascalCase keys; this maps them to idiomatic Kotlin names.
@OptIn(ExperimentalSerializationApi::class)
private val json = Json {
ignoreUnknownKeys = true
namingStrategy = JsonNamingStrategy { _, _, name -> name.replaceFirstChar(Char::uppercaseChar) }
}
@Serializable
data class Manifest(
val poetsCount: Int = 0,
val poemsCount: Int = 0,
val generatedAtUtc: String? = null,
val poets: List<PoetRef> = emptyList(),
)
@Serializable
data class PoetRef(val id: Int = 0, val nickname: String = "", val fullUrl: String = "") {
val slug get() = fullUrl.trim('/')
// ponytail: derived from the slug instead of fetching 240 poet.json files just for avatars.
// Note the host: poet.json's own ImageUrl field points at ganjoor.net, which 404s for these.
val imageUrl get() = "https://api.ganjoor.net/api/ganjoor/poet/image/$slug.gif"
}
@Serializable
data class Poet(
val id: Int = 0,
val name: String = "",
val nickname: String = "",
val description: String? = null,
val fullUrl: String = "",
val imageUrl: String? = null,
val birthYearInLHijri: Int? = null,
val deathYearInLHijri: Int? = null,
val birthPlace: String? = null,
val deathPlace: String? = null,
)
@Serializable
data class Category(
val id: Int = 0,
val poetId: Int = 0,
val title: String = "",
val fullUrl: String = "",
val description: String? = null,
val childCats: List<Category> = emptyList(),
val poems: List<PoemRef> = emptyList(),
)
@Serializable
data class PoemRef(val id: Int = 0, val title: String = "", val fullUrl: String = "")
@Serializable
data class Metre(val rhythm: String? = null)
@Serializable
data class Poem(
val id: Int = 0,
val title: String = "",
val fullTitle: String = "",
val fullUrl: String = "",
val rhymeLetters: String? = null,
val sourceName: String? = null,
val poemSummary: String? = null,
val metre: Metre? = null,
val verses: List<Verse> = emptyList(),
)
@Serializable
data class Verse(
val vOrder: Int = 0,
val position: String = RIGHT,
val text: String = "",
val coupletIndex: Int? = null,
val coupletSummary: String? = null,
) {
companion object {
const val RIGHT = "Right"
const val LEFT = "Left"
const val CENTERED_1 = "CenteredVerse1"
const val CENTERED_2 = "CenteredVerse2"
}
}
/** The second half of a couplet, keyed by the first half it belongs to. */
private val coupletPairs = mapOf(
Verse.LEFT to Verse.RIGHT,
Verse.CENTERED_2 to Verse.CENTERED_1,
)
/**
* Groups verses into couplets: Right+Left (and CenteredVerse1+2) are the two hemistichs of one
* line of poetry; anything else (Single, Paragraph, Comment) stands on its own.
*/
fun List<Verse>.couplets(): List<List<Verse>> {
val out = mutableListOf<MutableList<Verse>>()
for (verse in sortedBy { it.vOrder }) {
val open = out.lastOrNull()
if (open != null && open.size == 1 && coupletPairs[verse.position] == open[0].position) {
open += verse
} else {
out += mutableListOf(verse)
}
}
return out
}
object Ganjoor {
private val base = DATA_BASE.toHttpUrl()
private lateinit var http: OkHttpClient
fun init(context: 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.
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()
val response = try {
http.newCall(request).execute()
} catch (offline: IOException) {
// Stale-while-offline: serve the cached copy rather than failing the screen.
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())
}
}
suspend fun manifest(): Manifest = get("manifest.json")
suspend fun poet(slug: String): Poet = get("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")
/** @param fullUrl a Ganjoor poem URL, e.g. `/hafez/ghazal/sh1`. */
suspend fun poem(fullUrl: String): Poem = get("poets${fullUrl.trimEnd('/')}.json")
}

View File

@ -0,0 +1,123 @@
package com.ganjoor.android.ui
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
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.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.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.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.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.ganjoor.android.R
import com.ganjoor.android.data.Ganjoor
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CategoryScreen(
fullUrl: String,
onBack: () -> Unit,
onCategory: (String) -> Unit,
onPoem: (String) -> Unit,
) {
Load(key = fullUrl, block = { Ganjoor.category(fullUrl) }) { cat ->
Scaffold(
topBar = {
TopAppBar(
title = {
Text(cat.title, maxLines = 1, overflow = TextOverflow.Ellipsis)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back))
}
},
actions = { ReadingSettingsAction() },
)
}
) { insets ->
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
top = insets.calculateTopPadding(),
bottom = insets.calculateBottomPadding() + 16.dp,
),
) {
cat.description?.takeIf { it.isNotBlank() }?.let { description ->
item { Description(description) }
}
items(cat.childCats, key = { "c${it.id}" }) { child ->
NavRow(child.title, isCategory = true) { onCategory(child.fullUrl) }
}
items(cat.poems, key = { "p${it.id}" }) { poem ->
NavRow(poem.title, isCategory = false) { onPoem(poem.fullUrl) }
}
}
}
}
}
@Composable
private fun NavRow(title: String, isCategory: Boolean, onClick: () -> Unit) {
ListItem(
headlineContent = { Text(title) },
// A collection drills down into more lists; a poem is the leaf you read.
leadingContent = if (!isCategory) null else {
{
Icon(
imageVector = Icons.AutoMirrored.Filled.List,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
},
trailingContent = if (!isCategory) null else {
{ Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null) }
},
modifier = Modifier.clickable(onClick = onClick),
)
HorizontalDivider()
}
/** Poet biographies run long, so start clamped and expand on tap. */
@Composable
private fun Description(text: String) {
var expanded by remember(text) { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxWidth()
.clickable { expanded = !expanded }
.padding(16.dp)
.animateContentSize()
) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = if (expanded) Int.MAX_VALUE else 4,
overflow = TextOverflow.Ellipsis,
)
}
}

View File

@ -0,0 +1,61 @@
package com.ganjoor.android.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
import kotlinx.serialization.Serializable
// Routes carry Ganjoor URLs (/hafez/ghazal/sh1) rather than numeric ids: the data set is laid
// out by URL, so no id index lookup is needed. Nav percent-encodes the slashes.
@Serializable
object PoetsRoute
@Serializable
data class CategoryRoute(val url: String)
@Serializable
data class PoemRoute(val url: String)
@Composable
fun GanjoorApp() {
val nav = rememberNavController()
var settingsOpen by rememberSaveable { mutableStateOf(false) }
CompositionLocalProvider(LocalOpenReadingSettings provides { settingsOpen = true }) {
NavHost(navController = nav, startDestination = PoetsRoute) {
composable<PoetsRoute> {
PoetsScreen(onPoet = { nav.navigate(CategoryRoute(it)) })
}
composable<CategoryRoute> { entry ->
CategoryScreen(
fullUrl = entry.toRoute<CategoryRoute>().url,
onBack = { nav.navigateUp() },
onCategory = { nav.navigate(CategoryRoute(it)) },
onPoem = { nav.navigate(PoemRoute(it)) },
)
}
composable<PoemRoute> { entry ->
PoemScreen(
fullUrl = entry.toRoute<PoemRoute>().url,
onBack = { nav.navigateUp() },
// Reading on through a divan replaces the current poem, so Back returns to
// the list instead of unwinding every poem read along the way.
onPoem = { url ->
nav.navigate(PoemRoute(url)) {
popUpTo<PoemRoute> { inclusive = true }
}
},
)
}
}
}
if (settingsOpen) ReadingSettingsSheet(onDismiss = { settingsOpen = false })
}

View File

@ -0,0 +1,58 @@
package com.ganjoor.android.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.ganjoor.android.R
/**
* 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.
*/
@Composable
fun <T> Load(key: Any?, block: suspend () -> T, content: @Composable (T) -> Unit) {
var attempt by remember(key) { mutableIntStateOf(0) }
val result by produceState<Result<T>?>(null, key, attempt) { value = runCatching { block() } }
result.let { outcome ->
when {
outcome == null -> Box(Modifier.fillMaxSize(), Alignment.Center) {
CircularProgressIndicator()
}
outcome.isFailure -> Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(R.string.load_failed),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
)
TextButton(onClick = { attempt++ }) { Text(stringResource(R.string.retry)) }
}
else -> content(outcome.getOrThrow())
}
}
}

View File

@ -0,0 +1,204 @@
package com.ganjoor.android.ui
import androidx.compose.foundation.layout.Arrangement
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
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.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.stringResource
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.Ganjoor
import com.ganjoor.android.data.PoemRef
import com.ganjoor.android.data.Verse
import com.ganjoor.android.data.couplets
import com.ganjoor.android.ui.theme.readingStyle
@OptIn(ExperimentalMaterial3Api::class)
@Composable
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 couplets = remember(poem) { poem.verses.couplets() }
// Siblings come from the parent category, which is a much bigger file than the poem, so
// it loads after the poem is already on screen.
var siblings by remember(fullUrl) { mutableStateOf<List<PoemRef>>(emptyList()) }
LaunchedEffect(fullUrl) {
siblings = runCatching {
Ganjoor.category(fullUrl.substringBeforeLast('/')).poems
}.getOrDefault(emptyList())
}
val here = siblings.indexOfFirst { it.fullUrl == fullUrl }
Scaffold(
topBar = {
TopAppBar(
title = { Text(poem.title, maxLines = 1, overflow = TextOverflow.Ellipsis) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back))
}
},
actions = { ReadingSettingsAction() },
)
}
) { insets ->
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 20.dp,
end = 20.dp,
top = insets.calculateTopPadding() + 8.dp,
bottom = insets.calculateBottomPadding() + 32.dp,
),
) {
item {
Column(Modifier.padding(bottom = 12.dp)) {
Text(
text = poem.fullTitle,
style = MaterialTheme.typography.titleMedium,
)
poem.metre?.rhythm?.let { rhythm ->
Text(
text = rhythm,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
items(couplets) { couplet -> Couplet(couplet, style, prefs.showSummaries) }
if (prefs.showSummaries) {
poem.poemSummary?.takeIf { it.isNotBlank() }?.let { summary ->
item { PoemSummary(summary) }
}
}
item {
SiblingNav(
previous = if (here > 0) siblings[here - 1] else null,
next = if (here >= 0) siblings.getOrNull(here + 1) else null,
onPoem = onPoem,
)
}
poem.sourceName?.takeIf { it.isNotBlank() }?.let { source ->
item {
Text(
text = stringResource(R.string.source, source),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 16.dp),
)
}
}
}
}
}
}
/**
* 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.
*/
@Composable
private fun Couplet(
couplet: List<Verse>,
style: androidx.compose.ui.text.TextStyle,
showSummaries: Boolean,
) {
Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
couplet.forEach { verse ->
Text(
text = verse.text,
style = style,
textAlign = when (verse.position) {
Verse.RIGHT -> TextAlign.Start
Verse.LEFT -> TextAlign.End
Verse.CENTERED_1, Verse.CENTERED_2 -> TextAlign.Center
// Single / Paragraph / Comment: prose, so let it fill the column.
else -> TextAlign.Justify
},
modifier = Modifier.fillMaxWidth(),
)
}
if (showSummaries) {
couplet.firstNotNullOfOrNull { it.coupletSummary }
?.takeIf { it.isNotBlank() }
?.let { summary ->
Text(
text = summary,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp, bottom = 8.dp),
)
}
}
}
}
@Composable
private fun PoemSummary(summary: String) {
Column(Modifier.fillMaxWidth().padding(top = 24.dp)) {
HorizontalDivider()
Text(
text = stringResource(R.string.summary_ai_note),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 12.dp),
)
Text(
text = summary,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun SiblingNav(previous: PoemRef?, next: PoemRef?, onPoem: (String) -> Unit) {
if (previous == null && next == null) return
Row(
modifier = Modifier.fillMaxWidth().padding(top = 24.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
previous?.let {
TextButton(onClick = { onPoem(it.fullUrl) }) {
Text(stringResource(R.string.previous_poem, it.title))
}
}
next?.let {
TextButton(onClick = { onPoem(it.fullUrl) }) {
Text(stringResource(R.string.next_poem, it.title))
}
}
}
}

View File

@ -0,0 +1,131 @@
package com.ganjoor.android.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
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.Search
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
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 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) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.app_name)) },
actions = { ReadingSettingsAction() },
)
}
) { insets ->
Load(key = Unit, block = { Ganjoor.manifest() }) { manifest ->
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) }
}
LazyVerticalGrid(
columns = GridCells.Adaptive(132.dp),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 12.dp,
end = 12.dp,
top = insets.calculateTopPadding() + 8.dp,
bottom = insets.calculateBottomPadding() + 16.dp,
),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item(span = { GridItemSpan(maxLineSpan) }) {
OutlinedTextField(
value = query,
onValueChange = { query = it },
label = { Text(stringResource(R.string.search_poets)) },
leadingIcon = { Icon(Icons.Default.Search, null) },
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp),
)
}
items(poets, key = { it.id }) { poet ->
PoetCard(poet) { onPoet(poet.fullUrl) }
}
}
}
}
}
@Composable
private fun PoetCard(poet: PoetRef, onClick: () -> Unit) {
Card(modifier = Modifier.clickable(onClick = onClick)) {
Column(
modifier = Modifier.fillMaxWidth().padding(12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
// Not every poet has a portrait; the initial sits underneath so a miss isn't a hole.
Box(
modifier = Modifier
.size(84.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.secondaryContainer),
contentAlignment = Alignment.Center,
) {
Text(
text = poet.nickname.take(1),
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
AsyncImage(
model = poet.imageUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
}
Text(
text = poet.nickname,
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}

View File

@ -0,0 +1,133 @@
package com.ganjoor.android.ui
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.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Slider
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.ganjoor.android.R
import com.ganjoor.android.ui.theme.readingStyle
/**
* Opens the reading settings sheet. The sheet itself lives at the root of [GanjoorApp]: a
* ModalBottomSheet composes in place rather than in its own window, so hanging it off the top
* bar's action slot would clip it to the height of the app bar.
*/
val LocalOpenReadingSettings = staticCompositionLocalOf<() -> Unit> {
error("No reading settings host")
}
/** Top-bar button that opens the reading settings sheet. */
@Composable
fun ReadingSettingsAction() {
val open = LocalOpenReadingSettings.current
IconButton(onClick = open) {
Icon(Icons.Default.Settings, stringResource(R.string.reading_settings))
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReadingSettingsSheet(onDismiss: () -> Unit) {
val settings = LocalSettings.current
val prefs = settings.value
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
modifier = Modifier
// Inset first, then scroll: the viewport has to stop above the nav bar,
// otherwise the content scrolls underneath it. The sheet opens half-height,
// so everything below the fold has to be reachable.
.navigationBarsPadding()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp)
.padding(bottom = 32.dp),
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)) },
)
}
}
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)) },
)
}
}
Label(R.string.text_size)
Slider(
value = prefs.fontSize,
onValueChange = { size -> settings.update { it.copy(fontSize = size) } },
valueRange = 14f..40f,
steps = 12,
)
// Live preview, so the size and font choice can be judged before closing the sheet.
Text(
text = stringResource(R.string.font_preview),
style = readingStyle(prefs.font, prefs.fontSize),
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,
)
}
Switch(
checked = prefs.showSummaries,
onCheckedChange = { on -> settings.update { it.copy(showSummaries = on) } },
)
}
}
}
}
@Composable
private fun Label(resId: Int) {
Text(
text = stringResource(resId),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 12.dp),
)
}

View File

@ -0,0 +1,61 @@
package com.ganjoor.android.ui
import android.content.Context
import androidx.annotation.StringRes
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.core.content.edit
import com.ganjoor.android.R
enum class ThemeMode(@StringRes val label: Int) {
System(R.string.theme_system),
Light(R.string.theme_light),
Dark(R.string.theme_dark),
Sepia(R.string.theme_sepia),
SepiaDark(R.string.theme_sepia_dark),
}
enum class ReadingFont(@StringRes val label: Int) {
Nastaliq(R.string.font_nastaliq),
Naskh(R.string.font_naskh),
}
data class Prefs(
val theme: ThemeMode = ThemeMode.System,
val font: ReadingFont = ReadingFont.Nastaliq,
val fontSize: Float = 22f,
val showSummaries: Boolean = false,
)
/** Reading preferences, kept in SharedPreferences and read once at startup. */
class Settings(context: Context) {
private val prefs = context.getSharedPreferences("ganjoor", Context.MODE_PRIVATE)
var value by mutableStateOf(
Prefs(
theme = enumOrDefault(prefs.getString("theme", null), ThemeMode.System),
font = enumOrDefault(prefs.getString("font", null), ReadingFont.Nastaliq),
fontSize = prefs.getFloat("fontSize", 22f),
showSummaries = prefs.getBoolean("showSummaries", false),
)
)
private set
fun update(block: (Prefs) -> Prefs) {
value = block(value).also { p ->
prefs.edit {
putString("theme", p.theme.name)
putString("font", p.font.name)
putFloat("fontSize", p.fontSize)
putBoolean("showSummaries", p.showSummaries)
}
}
}
}
private inline fun <reified T : Enum<T>> enumOrDefault(name: String?, fallback: T): T =
runCatching { enumValueOf<T>(name!!) }.getOrDefault(fallback)
val LocalSettings = staticCompositionLocalOf<Settings> { error("No Settings provided") }

View File

@ -1,11 +0,0 @@
package com.ganjoor.android.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)

View File

@ -1,58 +1,143 @@
package com.ganjoor.android.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
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.ThemeMode
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
// Persian tilework: turquoise with saffron accents.
private val LightScheme = lightColorScheme(
primary = Color(0xFF00696E),
onPrimary = Color.White,
primaryContainer = Color(0xFF9CF1F6),
onPrimaryContainer = Color(0xFF002021),
secondary = Color(0xFF8B5000),
onSecondary = Color.White,
secondaryContainer = Color(0xFFFFDCBE),
onSecondaryContainer = Color(0xFF2C1600),
background = Color(0xFFFAFDFC),
onBackground = Color(0xFF191C1C),
surface = Color(0xFFFAFDFC),
onSurface = Color(0xFF191C1C),
surfaceVariant = Color(0xFFDAE4E4),
onSurfaceVariant = Color(0xFF3F4949),
surfaceBright = Color(0xFFFAFDFC),
surfaceDim = Color(0xFFD9DDDC),
surfaceContainerLowest = Color(0xFFFFFFFF),
surfaceContainerLow = Color(0xFFF3F7F6),
surfaceContainer = Color(0xFFEEF1F0),
surfaceContainerHigh = Color(0xFFE8ECEB),
surfaceContainerHighest = Color(0xFFE2E6E5),
outline = Color(0xFF6F7979),
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
private val DarkScheme = darkColorScheme(
primary = Color(0xFF80D4DA),
onPrimary = Color(0xFF003739),
primaryContainer = Color(0xFF004F53),
onPrimaryContainer = Color(0xFF9CF1F6),
secondary = Color(0xFFFFB873),
onSecondary = Color(0xFF4A2800),
secondaryContainer = Color(0xFF693C00),
onSecondaryContainer = Color(0xFFFFDCBE),
background = Color(0xFF191C1C),
onBackground = Color(0xFFE0E3E2),
surface = Color(0xFF191C1C),
onSurface = Color(0xFFE0E3E2),
surfaceVariant = Color(0xFF3F4949),
onSurfaceVariant = Color(0xFFBEC8C8),
surfaceBright = Color(0xFF3F4242),
surfaceDim = Color(0xFF191C1C),
surfaceContainerLowest = Color(0xFF141616),
surfaceContainerLow = Color(0xFF212424),
surfaceContainer = Color(0xFF252828),
surfaceContainerHigh = Color(0xFF2F3333),
surfaceContainerHighest = Color(0xFF3A3E3E),
outline = Color(0xFF899393),
)
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
// Aged paper, for long reading sessions.
private val SepiaScheme = lightColorScheme(
primary = Color(0xFF7A4E24),
onPrimary = Color.White,
primaryContainer = Color(0xFFE8D3B5),
onPrimaryContainer = Color(0xFF2B1700),
secondary = Color(0xFF6B5B3E),
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
secondaryContainer = Color(0xFFEADFC4),
onSecondaryContainer = Color(0xFF231A05),
background = Color(0xFFF6ECD9),
onBackground = Color(0xFF3A2E1E),
surface = Color(0xFFF1E5CE),
onSurface = Color(0xFF3A2E1E),
surfaceVariant = Color(0xFFE5D8BC),
onSurfaceVariant = Color(0xFF5A4C35),
surfaceBright = Color(0xFFF8F0E0),
surfaceDim = Color(0xFFDBD0B8),
surfaceContainerLowest = Color(0xFFFFFAF0),
surfaceContainerLow = Color(0xFFF2E8D4),
surfaceContainer = Color(0xFFECE1CA),
surfaceContainerHigh = Color(0xFFE6DAC0),
surfaceContainerHighest = Color(0xFFE0D3B6),
outline = Color(0xFF8C7A5C),
)
// Warm low-light reading: dark, but without the blue cast of the regular dark theme.
private val SepiaDarkScheme = darkColorScheme(
primary = Color(0xFFD9A96C),
onPrimary = Color(0xFF3A2510),
primaryContainer = Color(0xFF53391C),
onPrimaryContainer = Color(0xFFF5DEBE),
secondary = Color(0xFFCFBE98),
onSecondary = Color(0xFF362C14),
secondaryContainer = Color(0xFF4D4228),
onSecondaryContainer = Color(0xFFEBDCB7),
background = Color(0xFF1C1710),
onBackground = Color(0xFFE6DAC4),
surface = Color(0xFF241D14),
onSurface = Color(0xFFE6DAC4),
surfaceVariant = Color(0xFF3B3225),
onSurfaceVariant = Color(0xFFCCBFA6),
surfaceBright = Color(0xFF433A2D),
surfaceDim = Color(0xFF1C1710),
surfaceContainerLowest = Color(0xFF17120C),
surfaceContainerLow = Color(0xFF241D14),
surfaceContainer = Color(0xFF2A2218),
surfaceContainerHigh = Color(0xFF352C21),
surfaceContainerHighest = Color(0xFF40372B),
outline = Color(0xFF968A72),
)
@Composable
fun GanjoorappandroidTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
fun GanjoorTheme(mode: ThemeMode, content: @Composable () -> Unit) {
val dark = when (mode) {
ThemeMode.System -> isSystemInDarkTheme()
ThemeMode.Light, ThemeMode.Sepia -> false
ThemeMode.Dark, ThemeMode.SepiaDark -> true
}
val scheme = when (mode) {
ThemeMode.Sepia -> SepiaScheme
ThemeMode.SepiaDark -> SepiaDarkScheme
else -> if (dark) DarkScheme else LightScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
WindowCompat.getInsetsController(window, view).apply {
isAppearanceLightStatusBars = !dark
isAppearanceLightNavigationBars = !dark
}
}
}
MaterialTheme(colorScheme = scheme, typography = GanjoorTypography, content = content)
}

View File

@ -1,34 +1,55 @@
package com.ganjoor.android.ui.theme
import androidx.compose.material3.Typography
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.FontWeight
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.sp
import com.ganjoor.android.R
import com.ganjoor.android.ui.ReadingFont
// Set of Material typography styles to start with
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
/**
* 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.
*/
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),
)
/**
* 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 {
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,
fontSize = size,
lineHeight = size * leading,
platformStyle = PlatformTextStyle(includeFontPadding = true),
)
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)
}

View File

@ -1,3 +1,24 @@
<resources>
<string name="app_name">ganjoor-app-android</string>
</resources>
<string name="app_name">Ganjoor</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="reading_settings">Reading settings</string>
<string name="theme">Theme</string>
<string name="theme_system">System</string>
<string name="theme_light">Light</string>
<string name="theme_dark">Dark</string>
<string name="theme_sepia">Sepia</string>
<string name="theme_sepia_dark">Sepia night</string>
<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="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>
</resources>

View File

@ -0,0 +1,68 @@
package com.ganjoor.android
import com.ganjoor.android.data.Verse
import com.ganjoor.android.data.couplets
import org.junit.Assert.assertEquals
import org.junit.Test
class CoupletsTest {
private fun verse(order: Int, position: String) =
Verse(vOrder = order, position = position, text = "v$order")
@Test
fun `right and left hemistichs pair into one couplet`() {
val grouped = listOf(
verse(1, Verse.RIGHT),
verse(2, Verse.LEFT),
verse(3, Verse.RIGHT),
verse(4, Verse.LEFT),
).couplets()
assertEquals(2, grouped.size)
assertEquals(listOf("v1", "v2"), grouped[0].map { it.text })
assertEquals(listOf("v3", "v4"), grouped[1].map { it.text })
}
@Test
fun `out of order verses are sorted before grouping`() {
val grouped = listOf(verse(2, Verse.LEFT), verse(1, Verse.RIGHT)).couplets()
assertEquals(1, grouped.size)
assertEquals(listOf("v1", "v2"), grouped[0].map { it.text })
}
@Test
fun `prose and single verses each stand alone`() {
val grouped = listOf(
verse(1, "Paragraph"),
verse(2, "Single"),
verse(3, Verse.RIGHT),
verse(4, Verse.LEFT),
).couplets()
assertEquals(listOf(1, 1, 2), grouped.map { it.size })
}
@Test
fun `a left hemistich without its right half is not swallowed by the previous couplet`() {
val grouped = listOf(
verse(1, Verse.RIGHT),
verse(2, Verse.LEFT),
verse(3, Verse.LEFT),
).couplets()
assertEquals(listOf(2, 1), grouped.map { it.size })
}
@Test
fun `centered verses pair with each other, not with hemistichs`() {
val grouped = listOf(
verse(1, Verse.CENTERED_1),
verse(2, Verse.CENTERED_2),
verse(3, Verse.RIGHT),
verse(4, Verse.CENTERED_2),
).couplets()
assertEquals(listOf(2, 1, 1), grouped.map { it.size })
}
}

View File

@ -1,17 +0,0 @@
package com.ganjoor.android
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

View File

@ -2,4 +2,5 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.compose) apply false
}
alias(libs.plugins.kotlin.serialization) apply false
}

View File

@ -1,21 +1,23 @@
[versions]
agp = "9.4.1"
coreKtx = "1.10.1"
kotlin = "2.2.10"
composeBom = "2026.02.01"
coreKtx = "1.19.1"
activityCompose = "1.13.0"
lifecycle = "2.11.0"
navigation = "2.10.2"
okhttp = "4.12.0"
kotlinxSerialization = "1.9.0"
coil = "3.2.0"
junit = "4.13.2"
junitVersion = "1.1.5"
espressoCore = "3.5.1"
lifecycleRuntimeKtx = "2.6.1"
activityCompose = "1.8.0"
kotlin = "2.2.10"
composeBom = "2026.02.01"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
@ -24,8 +26,16 @@ androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "u
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }