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:
parent
46ba109207
commit
d9915eac6c
21
LICENSE
Normal file
21
LICENSE
Normal 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
96
README.md
Normal 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.
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
173
app/src/main/java/com/ganjoor/android/data/Ganjoor.kt
Normal file
173
app/src/main/java/com/ganjoor/android/data/Ganjoor.kt
Normal 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")
|
||||
}
|
||||
123
app/src/main/java/com/ganjoor/android/ui/CategoryScreen.kt
Normal file
123
app/src/main/java/com/ganjoor/android/ui/CategoryScreen.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
61
app/src/main/java/com/ganjoor/android/ui/GanjoorApp.kt
Normal file
61
app/src/main/java/com/ganjoor/android/ui/GanjoorApp.kt
Normal 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 })
|
||||
}
|
||||
58
app/src/main/java/com/ganjoor/android/ui/Load.kt
Normal file
58
app/src/main/java/com/ganjoor/android/ui/Load.kt
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
204
app/src/main/java/com/ganjoor/android/ui/PoemScreen.kt
Normal file
204
app/src/main/java/com/ganjoor/android/ui/PoemScreen.kt
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
131
app/src/main/java/com/ganjoor/android/ui/PoetsScreen.kt
Normal file
131
app/src/main/java/com/ganjoor/android/ui/PoetsScreen.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
133
app/src/main/java/com/ganjoor/android/ui/ReadingSettings.kt
Normal file
133
app/src/main/java/com/ganjoor/android/ui/ReadingSettings.kt
Normal 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),
|
||||
)
|
||||
}
|
||||
61
app/src/main/java/com/ganjoor/android/ui/Settings.kt
Normal file
61
app/src/main/java/com/ganjoor/android/ui/Settings.kt
Normal 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") }
|
||||
@ -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)
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
*/
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
68
app/src/test/java/com/ganjoor/android/CoupletsTest.kt
Normal file
68
app/src/test/java/com/ganjoor/android/CoupletsTest.kt
Normal 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 })
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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" }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user