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:
1 parent
46ba109207
commit
d9915eac6c
21 files changed
+1374
-138
No files matched your search
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user