diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..971a32c --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bcef47a --- /dev/null +++ b/README.md @@ -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. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 780694e..ac7c0f3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) -} \ No newline at end of file +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 94906e5..c0a5691 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ + + - 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") - } -} \ No newline at end of file diff --git a/app/src/main/java/com/ganjoor/android/data/Ganjoor.kt b/app/src/main/java/com/ganjoor/android/data/Ganjoor.kt new file mode 100644 index 0000000..01c6d6a --- /dev/null +++ b/app/src/main/java/com/ganjoor/android/data/Ganjoor.kt @@ -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 = 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 = emptyList(), + val poems: List = 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 = 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.couplets(): List> { + val out = mutableListOf>() + 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 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(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") +} diff --git a/app/src/main/java/com/ganjoor/android/ui/CategoryScreen.kt b/app/src/main/java/com/ganjoor/android/ui/CategoryScreen.kt new file mode 100644 index 0000000..cf6495e --- /dev/null +++ b/app/src/main/java/com/ganjoor/android/ui/CategoryScreen.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/com/ganjoor/android/ui/GanjoorApp.kt b/app/src/main/java/com/ganjoor/android/ui/GanjoorApp.kt new file mode 100644 index 0000000..2c9cec1 --- /dev/null +++ b/app/src/main/java/com/ganjoor/android/ui/GanjoorApp.kt @@ -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 { + PoetsScreen(onPoet = { nav.navigate(CategoryRoute(it)) }) + } + composable { entry -> + CategoryScreen( + fullUrl = entry.toRoute().url, + onBack = { nav.navigateUp() }, + onCategory = { nav.navigate(CategoryRoute(it)) }, + onPoem = { nav.navigate(PoemRoute(it)) }, + ) + } + composable { entry -> + PoemScreen( + fullUrl = entry.toRoute().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 { inclusive = true } + } + }, + ) + } + } + } + + if (settingsOpen) ReadingSettingsSheet(onDismiss = { settingsOpen = false }) +} diff --git a/app/src/main/java/com/ganjoor/android/ui/Load.kt b/app/src/main/java/com/ganjoor/android/ui/Load.kt new file mode 100644 index 0000000..3d85b5e --- /dev/null +++ b/app/src/main/java/com/ganjoor/android/ui/Load.kt @@ -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 Load(key: Any?, block: suspend () -> T, content: @Composable (T) -> Unit) { + var attempt by remember(key) { mutableIntStateOf(0) } + val result by produceState?>(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()) + } + } +} diff --git a/app/src/main/java/com/ganjoor/android/ui/PoemScreen.kt b/app/src/main/java/com/ganjoor/android/ui/PoemScreen.kt new file mode 100644 index 0000000..0e9638d --- /dev/null +++ b/app/src/main/java/com/ganjoor/android/ui/PoemScreen.kt @@ -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>(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, + 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)) + } + } + } +} diff --git a/app/src/main/java/com/ganjoor/android/ui/PoetsScreen.kt b/app/src/main/java/com/ganjoor/android/ui/PoetsScreen.kt new file mode 100644 index 0000000..d913e0a --- /dev/null +++ b/app/src/main/java/com/ganjoor/android/ui/PoetsScreen.kt @@ -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, + ) + } + } +} diff --git a/app/src/main/java/com/ganjoor/android/ui/ReadingSettings.kt b/app/src/main/java/com/ganjoor/android/ui/ReadingSettings.kt new file mode 100644 index 0000000..f2f45b6 --- /dev/null +++ b/app/src/main/java/com/ganjoor/android/ui/ReadingSettings.kt @@ -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), + ) +} diff --git a/app/src/main/java/com/ganjoor/android/ui/Settings.kt b/app/src/main/java/com/ganjoor/android/ui/Settings.kt new file mode 100644 index 0000000..3cd1037 --- /dev/null +++ b/app/src/main/java/com/ganjoor/android/ui/Settings.kt @@ -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 > enumOrDefault(name: String?, fallback: T): T = + runCatching { enumValueOf(name!!) }.getOrDefault(fallback) + +val LocalSettings = staticCompositionLocalOf { error("No Settings provided") } diff --git a/app/src/main/java/com/ganjoor/android/ui/theme/Color.kt b/app/src/main/java/com/ganjoor/android/ui/theme/Color.kt deleted file mode 100644 index cbe0719..0000000 --- a/app/src/main/java/com/ganjoor/android/ui/theme/Color.kt +++ /dev/null @@ -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) \ No newline at end of file diff --git a/app/src/main/java/com/ganjoor/android/ui/theme/Theme.kt b/app/src/main/java/com/ganjoor/android/ui/theme/Theme.kt index bbdbe70..0de98ec 100644 --- a/app/src/main/java/com/ganjoor/android/ui/theme/Theme.kt +++ b/app/src/main/java/com/ganjoor/android/ui/theme/Theme.kt @@ -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 - ) -} \ No newline at end of file + 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) +} diff --git a/app/src/main/java/com/ganjoor/android/ui/theme/Type.kt b/app/src/main/java/com/ganjoor/android/ui/theme/Type.kt index 898d351..323bc47 100644 --- a/app/src/main/java/com/ganjoor/android/ui/theme/Type.kt +++ b/app/src/main/java/com/ganjoor/android/ui/theme/Type.kt @@ -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 - ) - */ -) \ No newline at end of file +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4bcdf01..7a0d909 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,24 @@ - ganjoor-app-android - \ No newline at end of file + Ganjoor + Search poets + Back + Retry + Could not load this page. Check your connection and try again. + Reading settings + Theme + System + Light + Dark + Sepia + Sepia night + Font + Naskh + Nastaliq + الا یا ایها الساقی ادر کأسا و ناولها + Text size + Show summaries + AI-generated by Ganjoor, not by the poet + ‹ %s + %s › + Source: %s + diff --git a/app/src/test/java/com/ganjoor/android/CoupletsTest.kt b/app/src/test/java/com/ganjoor/android/CoupletsTest.kt new file mode 100644 index 0000000..9ef83e0 --- /dev/null +++ b/app/src/test/java/com/ganjoor/android/CoupletsTest.kt @@ -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 }) + } +} diff --git a/app/src/test/java/com/ganjoor/android/ExampleUnitTest.kt b/app/src/test/java/com/ganjoor/android/ExampleUnitTest.kt deleted file mode 100644 index 9bc0eaa..0000000 --- a/app/src/test/java/com/ganjoor/android/ExampleUnitTest.kt +++ /dev/null @@ -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) - } -} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 18318be..371e867 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,4 +2,5 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.compose) apply false -} \ No newline at end of file + alias(libs.plugins.kotlin.serialization) apply false +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d90d9f3..8c9def5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }