Compare commits
37 Commits
feature/la
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 12a1cdf1b6 | |||
| 38996943b5 | |||
| b6f103dc73 | |||
| 5a338d0e0a | |||
| f49d9f080f | |||
| e0537e3455 | |||
| 709a876348 | |||
|
|
37be11c7b3 | ||
| 6590a6b21a | |||
| 66d8b82648 | |||
|
|
c801d88ef3 | ||
| 331e0544bc | |||
| a725e66a9a | |||
|
|
31279bc5da | ||
|
|
23efc2afa3 | ||
| 270c28c9c9 | |||
| 3407f010c9 | |||
| 3486a89b56 | |||
|
|
701b984f4b | ||
| 41d776a35d | |||
| bcdad2e699 | |||
| ce26676d4e | |||
| f60f9b4079 | |||
| 488f48ad39 | |||
|
|
c3b22fdf0e | ||
| 936fd3cf3b | |||
|
|
64a67ddbbd | ||
|
|
aab8b2e3e1 | ||
| f21f4fa727 | |||
| b3f4e527f1 | |||
|
|
1a1c86ee64 | ||
|
|
af330d081f | ||
| a118fc2510 | |||
|
|
8451cc45fb | ||
|
|
f0c1dd5541 | ||
|
|
ebbf737dac | ||
|
|
cf65072af4 |
14
README.md
@ -13,6 +13,15 @@ An Android reader for [Ganjoor](https://ganjoor.net), the open archive of Persia
|
||||
240 poets and ~135,000 poems, laid out for comfortable long-form reading in Persian, Urdu
|
||||
and Arabic script, online or fully offline.
|
||||
|
||||
### ⬇ [Download ganjoor-0.4.1.apk](releases/ganjoor-0.4.1.apk)
|
||||
|
||||
Android 7.0 and up · 31.7 MB · [older releases](releases/) · `sha256 6c85da41…ce357615daa`
|
||||
|
||||
Android will call the developer unknown and offer to install anyway — it says that about every
|
||||
app installed outside a store. Allow installs from your browser once and it will go through.
|
||||
Every release is signed with the same key, so a new one upgrades the last without losing your
|
||||
bookmarks. [Build it yourself](#build) instead if you would rather.
|
||||
|
||||
Jetpack Compose, Material 3, `minSdk 24`. No account, no tracking, no server of its own.
|
||||
|
||||
## Reading
|
||||
@ -28,6 +37,11 @@ bar, and any span of text can be selected and copied the usual way.
|
||||
|
||||
The whole interface lays out and navigates right-to-left, whichever UI language is chosen.
|
||||
|
||||
On a tablet or an unfolded foldable (600dp and wider) the poets become a narrow column on the right,
|
||||
with the open poet's books, chapters and poems in columns beside it and the page in the rest of the
|
||||
screen. The columns narrow while a poem is open and can be hidden for a reader view. The dictionary
|
||||
and reading settings open in a panel to the left of the poem instead of over it. Phones are unchanged.
|
||||
|
||||
## Fonts
|
||||
|
||||
| Font | Used for | Licence |
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import org.gradle.api.tasks.PathSensitivity
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
@ -16,7 +17,7 @@ val keystoreProperties = Properties().apply {
|
||||
|
||||
// A delivered file should say what it is without being opened.
|
||||
base {
|
||||
archivesName = "ganjoor-0.3.0"
|
||||
archivesName = "ganjoor-0.4.1"
|
||||
}
|
||||
|
||||
android {
|
||||
@ -32,8 +33,8 @@ android {
|
||||
// Pre-1.0 while the app is still being shaped. versionCode only ever climbs: F-Droid
|
||||
// refuses an update that does not, and one changelog file per code lives in
|
||||
// fastlane/metadata/android/*/changelogs/.
|
||||
versionCode = 5
|
||||
versionName = "0.3.0"
|
||||
versionCode = 7
|
||||
versionName = "0.4.1"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@ -75,6 +76,14 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
// ReleaseDocsTest reads these, so a change to either has to re-run the tests. Without this
|
||||
// Gradle sees only Kotlin sources, calls the task up to date, and a README that has fallen
|
||||
// behind the build sails through green.
|
||||
tasks.withType<Test>().configureEach {
|
||||
inputs.file(rootProject.file("README.md")).withPathSensitivity(PathSensitivity.RELATIVE)
|
||||
inputs.file(rootProject.file("releases/README.md")).withPathSensitivity(PathSensitivity.RELATIVE)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.activity.compose)
|
||||
|
||||
@ -261,7 +261,11 @@ internal fun letterOverlap(a: String, b: String): Float {
|
||||
}
|
||||
|
||||
/** The whole word surrounding [index], for turning a tap into something to look up. */
|
||||
internal fun wordAt(text: String, index: Int): String? {
|
||||
internal fun wordAt(text: String, index: Int): String? =
|
||||
wordRangeAt(text, index)?.let { text.substring(it) }
|
||||
|
||||
/** Where in [text] the word [wordAt] finds lies, so the reader can see which word was looked up. */
|
||||
internal fun wordRangeAt(text: String, index: Int): IntRange? {
|
||||
if (text.isEmpty()) return null
|
||||
val at = index.coerceIn(0, text.length - 1)
|
||||
if (!text[at].isWordChar()) return null
|
||||
@ -269,7 +273,29 @@ internal fun wordAt(text: String, index: Int): String? {
|
||||
while (start > 0 && text[start - 1].isWordChar()) start--
|
||||
var end = at
|
||||
while (end < text.length - 1 && text[end + 1].isWordChar()) end++
|
||||
return text.substring(start, end + 1).trim(ZWNJ).takeIf { it.length > 1 }
|
||||
// A joiner at either edge belongs to the neighbour, not to the word.
|
||||
while (start <= end && text[start] == ZWNJ) start++
|
||||
while (end >= start && text[end] == ZWNJ) end--
|
||||
return (start..end).takeIf { end - start + 1 > 1 }
|
||||
}
|
||||
|
||||
private fun Char.isWordChar() = this in ''..'ۿ' || this == ZWNJ
|
||||
/**
|
||||
* Letters, the marks that sit on them, and the joiner — but not the punctuation a line of verse
|
||||
* is pointed with, nor its digits.
|
||||
*
|
||||
* The Arabic block holds far more than letters: the comma ، the semicolon ؛ the question mark ؟
|
||||
* the full stop ۔ and both sets of Indic digits all live inside it. Spanning the whole block swept
|
||||
* them into the word, so tapping دستم in «ز دستم، صاحبدلان» looked up «دستم،», which no dictionary
|
||||
* carries and no near-word search rescues.
|
||||
*
|
||||
* Harakat stay in: they sit *inside* a word — منِ is one word — and normalise() strips them before
|
||||
* the lookup anyway. Tatweel stays for the same reason, stretching a letter without breaking it.
|
||||
*/
|
||||
private fun Char.isWordChar() =
|
||||
this == ZWNJ || (this in ''..'ۿ' && category in WORD_CATEGORIES)
|
||||
|
||||
private val WORD_CATEGORIES = setOf(
|
||||
CharCategory.OTHER_LETTER, // the letters themselves
|
||||
CharCategory.NON_SPACING_MARK, // harakat, which sit on a letter
|
||||
CharCategory.MODIFIER_LETTER, // tatweel, which stretches one
|
||||
)
|
||||
|
||||
@ -56,7 +56,12 @@ fun CategoryScreen(
|
||||
// System Back climbs the tree too, not the visit history.
|
||||
BackHandler(onBack = onUp)
|
||||
|
||||
Load(key = fullUrl, block = { Ganjoor.category(fullUrl) }) { cat ->
|
||||
Load(
|
||||
key = fullUrl,
|
||||
block = { Ganjoor.category(fullUrl) },
|
||||
// The bar stays up with Back already working; only the list waits.
|
||||
placeholder = { LoadingCategory(onUp, onHome) },
|
||||
) { cat ->
|
||||
// First lines are a separate, optional call; the list shows up without waiting for it.
|
||||
var excerpts by remember(cat.id) { mutableStateOf(emptyMap<Int, String>()) }
|
||||
LaunchedEffect(cat.id) {
|
||||
@ -124,12 +129,38 @@ fun CategoryScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/** The category screen while its list is on the way: the real top bar over a skeleton list. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun LoadingCategory(onUp: () -> Unit, onHome: () -> Unit) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onUp) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back))
|
||||
}
|
||||
},
|
||||
// Home and the reading settings belong to the app, not to this page, so they are
|
||||
// there from the first frame. Downloading needs to know which poet this is.
|
||||
actions = {
|
||||
HomeAction(onHome)
|
||||
ReadingSettingsAction()
|
||||
},
|
||||
)
|
||||
}
|
||||
) { insets ->
|
||||
SkeletonList(Modifier.padding(insets))
|
||||
}
|
||||
}
|
||||
|
||||
/** A poet's root URL is a single segment (`/hafez`); anything deeper is one of their books. */
|
||||
private fun poetSlug(fullUrl: String): String? =
|
||||
internal fun poetSlug(fullUrl: String): String? =
|
||||
fullUrl.trim('/').takeIf { it.isNotEmpty() && !it.contains('/') }
|
||||
|
||||
@Composable
|
||||
private fun PoetDownloadAction(slug: String) {
|
||||
internal fun PoetDownloadAction(slug: String) {
|
||||
val progress = Downloads.running[slug]
|
||||
val saved = remember(slug, Downloads.revision) { Offline.isSaved(slug) }
|
||||
|
||||
|
||||
695
app/src/main/java/com/ganjoor/android/ui/ColumnBrowser.kt
Normal file
@ -0,0 +1,695 @@
|
||||
package com.ganjoor.android.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.expandHorizontally
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.shrinkHorizontally
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.AnimationVector1D
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
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.lazy.items
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.filled.Menu
|
||||
import androidx.compose.material3.Card
|
||||
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.SmallFloatingActionButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.VerticalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
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.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.ganjoor.android.R
|
||||
import com.ganjoor.android.data.CatEntry
|
||||
import com.ganjoor.android.data.Category
|
||||
import com.ganjoor.android.data.Ganjoor
|
||||
import com.ganjoor.android.data.LocalPinnedPoets
|
||||
import com.ganjoor.android.data.Manifest
|
||||
import com.ganjoor.android.data.PoetRef
|
||||
import com.ganjoor.android.data.orderedEntries
|
||||
import com.ganjoor.android.data.parentUrl
|
||||
|
||||
/*
|
||||
* The tablet and unfolded-foldable layout. Right to left, as the app reads: a narrow column of
|
||||
* poets, then a column for each level of the open book (its books, a book's chapters, a
|
||||
* chapter's poems), then the open page in whatever room is left. Phones never see any of this;
|
||||
* GanjoorApp only switches to it at 600dp and wider.
|
||||
*/
|
||||
|
||||
/**
|
||||
* What the columns have loaded and where each one is scrolled to.
|
||||
*
|
||||
* Every browse step is still a navigation destination, so Back and the phone layout keep working
|
||||
* unchanged; this lives above the NavHost so that moving from one destination to the next
|
||||
* doesn't throw away the columns the reader can still see.
|
||||
*/
|
||||
@Stable
|
||||
class BrowserState {
|
||||
var manifest by mutableStateOf<Manifest?>(null)
|
||||
val categories = mutableStateMapOf<String, Category>()
|
||||
val excerpts = mutableStateMapOf<Int, Map<Int, String>>()
|
||||
val railState = LazyListState()
|
||||
private val listStates = HashMap<String, LazyListState>()
|
||||
private val widths = HashMap<String, Animatable<Float, AnimationVector1D>>()
|
||||
|
||||
fun listState(url: String): LazyListState = listStates.getOrPut(url) { LazyListState() }
|
||||
|
||||
/**
|
||||
* A column's width in dp, kept here rather than in the column: each tap is a new destination,
|
||||
* and a width remembered by the column would open it out from nothing again every time.
|
||||
*/
|
||||
fun width(url: String): Animatable<Float, AnimationVector1D> = widths.getOrPut(url) { Animatable(0f) }
|
||||
|
||||
suspend fun category(url: String): Category =
|
||||
categories[url] ?: Ganjoor.category(url).also { categories[url] = it }
|
||||
}
|
||||
|
||||
/**
|
||||
* The categories that get a column on the way to [url], poet first:
|
||||
* `/saadi/golestan/bab1/sh1` → `/saadi`, `/saadi/golestan`, `/saadi/golestan/bab1`.
|
||||
* A poem is read rather than listed, so its own URL gets no column; a category's does.
|
||||
*/
|
||||
internal fun columnUrls(url: String, isPoem: Boolean): List<String> {
|
||||
val segments = url.trim('/').split('/').filter { it.isNotEmpty() }
|
||||
val depth = if (isPoem) segments.size - 1 else segments.size
|
||||
return (1..depth).map { "/" + segments.take(it).joinToString("/") }
|
||||
}
|
||||
|
||||
/**
|
||||
* Newest column widest; older ones give their room to the page. While a poem is open every
|
||||
* column steps back further, so the reading gets most of the screen.
|
||||
*/
|
||||
private fun columnWidth(distance: Int, reading: Boolean): Dp = when {
|
||||
distance == 0 && !reading -> 224.dp
|
||||
distance == 0 || distance == 1 && !reading -> 168.dp
|
||||
else -> 132.dp
|
||||
}
|
||||
|
||||
private fun same(a: String?, b: String?) = a != null && b != null && a.trimEnd('/') == b.trimEnd('/')
|
||||
|
||||
private val CatEntry.url
|
||||
get() = when (this) {
|
||||
is CatEntry.Chapter -> category.fullUrl
|
||||
is CatEntry.Poem -> poem.fullUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* True while a side panel (the reading settings) is open beside the page on a large screen.
|
||||
* The columns step aside for it, so the page keeps its room, and come back when it closes.
|
||||
*/
|
||||
val LocalSidePanelOpen = compositionLocalOf { false }
|
||||
|
||||
/**
|
||||
* The columns beside [content]. [expanded] (840dp and up) shows up to three list columns;
|
||||
* narrower, only the newest one, with a way back up in its header.
|
||||
*
|
||||
* [content] gets the button that hides or shows the columns, to put in its own top bar.
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnBrowser(
|
||||
state: BrowserState,
|
||||
url: String,
|
||||
isPoem: Boolean,
|
||||
expanded: Boolean,
|
||||
onPoet: (String) -> Unit,
|
||||
onCategory: (String) -> Unit,
|
||||
onPoem: (String) -> Unit,
|
||||
content: @Composable (toggle: @Composable () -> Unit) -> Unit,
|
||||
) {
|
||||
val settings = LocalSettings.current
|
||||
// Hidden by the reader (reader view, remembered) or only while the settings panel is open.
|
||||
// The second never touches the saved choice, so closing the panel puts back exactly what
|
||||
// was there: the columns if they were showing, reader view if it was on.
|
||||
val makingRoom = LocalSidePanelOpen.current
|
||||
val hidden = settings.value.columnsHidden || makingRoom
|
||||
val columns = remember(url, isPoem) { columnUrls(url, isPoem) }
|
||||
// What is selected in each column: the next step of the path, down to the open poem.
|
||||
val path = if (isPoem) columns + url else columns
|
||||
val shown = columns.takeLast(if (expanded) 3 else 1)
|
||||
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
// Folding away slides the columns off to the right, where they live.
|
||||
AnimatedVisibility(
|
||||
visible = !hidden,
|
||||
enter = expandHorizontally(expandFrom = Alignment.Start) + fadeIn(),
|
||||
exit = shrinkHorizontally(shrinkTowards = Alignment.Start) + fadeOut(),
|
||||
) {
|
||||
Row(Modifier.fillMaxHeight()) {
|
||||
PoetRail(
|
||||
state = state,
|
||||
selected = path.firstOrNull(),
|
||||
compact = shown.size > 1 || isPoem,
|
||||
onPoet = onPoet,
|
||||
)
|
||||
VerticalDivider()
|
||||
shown.forEachIndexed { index, columnUrl ->
|
||||
key(columnUrl) {
|
||||
// Only the first visible column can have hidden ancestors to climb back
|
||||
// to, and a poet's own column climbs to the rail, which is already there.
|
||||
val up = parentUrl(columnUrl)
|
||||
?.takeIf { index == 0 && columns.size > shown.size }
|
||||
ListColumn(
|
||||
state = state,
|
||||
url = columnUrl,
|
||||
selected = path.getOrNull(path.indexOf(columnUrl) + 1),
|
||||
distance = shown.size - 1 - index,
|
||||
reading = isPoem,
|
||||
onUp = up?.let { parent -> { onCategory(parent) } },
|
||||
onCategory = onCategory,
|
||||
onPoem = onPoem,
|
||||
)
|
||||
VerticalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(Modifier.weight(1f).fillMaxHeight()) {
|
||||
content {
|
||||
// Hiding is in the top bar; bringing them back is the floating button below,
|
||||
// which is there whenever the reader has hidden them.
|
||||
if (!hidden) ColumnsToggle { settings.update { it.copy(columnsHidden = true) } }
|
||||
}
|
||||
// Reader view: the page has the whole screen, and one button, at the edge the
|
||||
// columns went to, brings them back as they were.
|
||||
// Not while the settings panel is open: the columns would stay away for it anyway.
|
||||
ShowColumnsButton(
|
||||
visible = hidden && !makingRoom,
|
||||
onShow = { settings.update { it.copy(columnsHidden = false) } },
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.navigationBarsPadding()
|
||||
.padding(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Brings the columns back from reader view.
|
||||
*
|
||||
* In its own composable because the Box it is placed in sits inside the browser's Row: with
|
||||
* RowScope still an implicit receiver there, `AnimatedVisibility` resolves to the row overload,
|
||||
* which takes no alignment and does not compile. A function of its own has only its own scope.
|
||||
*/
|
||||
@Composable
|
||||
private fun ShowColumnsButton(visible: Boolean, onShow: () -> Unit, modifier: Modifier = Modifier) {
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = scaleIn() + fadeIn(),
|
||||
exit = scaleOut() + fadeOut(),
|
||||
modifier = modifier,
|
||||
) {
|
||||
SmallFloatingActionButton(onClick = onShow) {
|
||||
Icon(Icons.Default.Menu, stringResource(R.string.show_columns))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Folds the columns away so the page has the whole screen (reader view). */
|
||||
@Composable
|
||||
private fun ColumnsToggle(onHide: () -> Unit) {
|
||||
IconButton(onClick = onHide) {
|
||||
Icon(painterResource(R.drawable.ic_menu_open), stringResource(R.string.hide_columns))
|
||||
}
|
||||
}
|
||||
|
||||
/** Every poet, portrait over name, in the same order the home screen uses. */
|
||||
@Composable
|
||||
private fun PoetRail(
|
||||
state: BrowserState,
|
||||
selected: String?,
|
||||
compact: Boolean,
|
||||
onPoet: (String) -> Unit,
|
||||
) {
|
||||
var attempt by remember { mutableIntStateOf(0) }
|
||||
var failed by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(attempt) {
|
||||
if (state.manifest == null) {
|
||||
failed = false
|
||||
runCatching { Ganjoor.manifest() }
|
||||
.onSuccess { state.manifest = it }
|
||||
.onFailure { failed = true }
|
||||
}
|
||||
}
|
||||
|
||||
val sort = LocalSettings.current.value.poetSort
|
||||
val pinned = LocalPinnedPoets.current
|
||||
val manifest = state.manifest
|
||||
val poets = remember(manifest, sort, pinned.items.toList()) {
|
||||
manifest?.let { orderPoets(it.poets, sort, pinned.items) }.orEmpty()
|
||||
}
|
||||
val width by animateDpAsState(if (compact) 80.dp else 96.dp, label = "rail width")
|
||||
|
||||
// Keep the open poet in view, without yanking the rail back while the reader scrolls it.
|
||||
LaunchedEffect(selected, poets.size) {
|
||||
val index = poets.indexOfFirst { same(it.fullUrl, selected) }
|
||||
if (index >= 0 && state.railState.layoutInfo.visibleItemsInfo.none { it.index == index }) {
|
||||
state.railState.scrollToItem(index)
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
modifier = Modifier.width(width).fillMaxHeight(),
|
||||
) {
|
||||
when {
|
||||
manifest != null -> LazyColumn(
|
||||
state = state.railState,
|
||||
modifier = Modifier.fillMaxSize().statusBarsPadding(),
|
||||
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
items(poets, key = { it.id }) { poet ->
|
||||
RailPoet(
|
||||
poet = poet,
|
||||
selected = same(poet.fullUrl, selected),
|
||||
compact = compact,
|
||||
onClick = { onPoet(poet.fullUrl) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
failed -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
TextButton(onClick = { attempt++ }) { Text(stringResource(R.string.retry)) }
|
||||
}
|
||||
|
||||
else -> SkeletonRail(Modifier.statusBarsPadding(), disc = if (compact) 44.dp else 56.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RailPoet(poet: PoetRef, selected: Boolean, compact: Boolean, onClick: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(if (selected) colors.secondaryContainer else Color.Transparent)
|
||||
.selectable(selected = selected, role = Role.Tab, onClick = onClick)
|
||||
.padding(horizontal = 4.dp, vertical = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
// A ring round the open poet's portrait, so the choice doesn't rest on the tile alone.
|
||||
val ring = if (selected) {
|
||||
Modifier.border(2.dp, colors.primary, CircleShape).padding(4.dp)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(if (compact) 44.dp else 56.dp)
|
||||
.then(ring)
|
||||
.clip(CircleShape)
|
||||
.background(colors.secondaryContainer),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
// The initial sits underneath, as on the home screen, so a missing portrait isn't a hole.
|
||||
Text(poet.nickname.take(1), color = colors.onSecondaryContainer)
|
||||
AsyncImage(
|
||||
model = poet.imageUrl,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = poet.nickname,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = if (selected) colors.onSecondaryContainer else colors.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One level of the open book: a category's chapters and poems, with [selected] marked.
|
||||
* [distance] counts the columns to its left; the further back, the narrower and terser it gets.
|
||||
*/
|
||||
@Composable
|
||||
private fun ListColumn(
|
||||
state: BrowserState,
|
||||
url: String,
|
||||
selected: String?,
|
||||
distance: Int,
|
||||
reading: Boolean,
|
||||
onUp: (() -> Unit)?,
|
||||
onCategory: (String) -> Unit,
|
||||
onPoem: (String) -> Unit,
|
||||
) {
|
||||
val target = columnWidth(distance, reading)
|
||||
// From nothing when the column first appears, so it opens out rather than popping in; after
|
||||
// that, from wherever it was, so an older column narrows as a new one opens.
|
||||
val width = state.width(url)
|
||||
LaunchedEffect(target) {
|
||||
width.animateTo(target.value, tween(300, easing = FastOutSlowInEasing))
|
||||
}
|
||||
|
||||
var attempt by remember(url) { mutableIntStateOf(0) }
|
||||
var failed by remember(url) { mutableStateOf(false) }
|
||||
LaunchedEffect(url, attempt) {
|
||||
failed = false
|
||||
runCatching { state.category(url) }.onFailure { failed = true }
|
||||
}
|
||||
|
||||
val cat = state.categories[url]
|
||||
// Older columns, and every column while a poem is open, drop first lines and wrap titles.
|
||||
val terse = distance > 0 || reading
|
||||
// First lines only where there's room to show them: the newest column.
|
||||
LaunchedEffect(cat?.id, terse) {
|
||||
if (cat != null && !terse && cat.poems.isNotEmpty() && cat.id !in state.excerpts) {
|
||||
state.excerpts[cat.id] = runCatching { Ganjoor.excerpts(cat.id) }.getOrDefault(emptyMap())
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
modifier = Modifier.width(width.value.dp).fillMaxHeight().clipToBounds(),
|
||||
) {
|
||||
Column(Modifier.fillMaxSize().statusBarsPadding()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 56.dp)
|
||||
.padding(horizontal = if (terse) 8.dp else 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (onUp != null) {
|
||||
IconButton(onClick = onUp) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back))
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = cat?.title.orEmpty(),
|
||||
style = if (terse) MaterialTheme.typography.titleSmall
|
||||
else MaterialTheme.typography.titleMedium,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
HorizontalDivider()
|
||||
|
||||
// Only this column waits: a skeleton of its rows, fading into the list when it lands.
|
||||
Crossfade(targetState = cat, label = "column") { loaded ->
|
||||
when {
|
||||
loaded != null -> {
|
||||
val cat = loaded
|
||||
val entries = remember(cat) { orderedEntries(cat) }
|
||||
val excerpts = if (terse) emptyMap() else state.excerpts[cat.id].orEmpty()
|
||||
val listState = state.listState(url)
|
||||
LaunchedEffect(selected, entries) {
|
||||
val index = entries.indexOfFirst { same(it.url, selected) }
|
||||
if (index >= 0 && listState.layoutInfo.visibleItemsInfo.none { it.index == index }) {
|
||||
listState.scrollToItem(index)
|
||||
}
|
||||
}
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
items(
|
||||
items = entries,
|
||||
key = { entry ->
|
||||
when (entry) {
|
||||
is CatEntry.Chapter -> "c${entry.category.id}"
|
||||
is CatEntry.Poem -> "p${entry.poem.id}"
|
||||
}
|
||||
},
|
||||
) { entry ->
|
||||
ColumnRow(
|
||||
entry = entry,
|
||||
selected = same(entry.url, selected),
|
||||
terse = terse,
|
||||
excerpt = (entry as? CatEntry.Poem)?.let { excerpts[it.poem.id] },
|
||||
onClick = {
|
||||
when (entry) {
|
||||
is CatEntry.Chapter -> onCategory(entry.category.fullUrl)
|
||||
is CatEntry.Poem -> onPoem(entry.poem.fullUrl)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
failed -> Column(Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.load_failed),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
TextButton(onClick = { attempt++ }) { Text(stringResource(R.string.retry)) }
|
||||
}
|
||||
|
||||
else -> SkeletonList(rows = 10, twoLines = !terse)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnRow(
|
||||
entry: CatEntry,
|
||||
selected: Boolean,
|
||||
terse: Boolean,
|
||||
excerpt: String?,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val title = when (entry) {
|
||||
is CatEntry.Chapter -> entry.category.title
|
||||
is CatEntry.Poem -> entry.poem.title
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(if (selected) colors.secondaryContainer else Color.Transparent)
|
||||
.selectable(selected = selected, role = Role.Tab, onClick = onClick)
|
||||
.heightIn(min = 48.dp)
|
||||
.padding(horizontal = if (terse) 8.dp else 12.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = title,
|
||||
style = if (terse) MaterialTheme.typography.bodyMedium
|
||||
else MaterialTheme.typography.bodyLarge,
|
||||
color = if (selected) colors.onSecondaryContainer else colors.onSurface,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
// A chapter opens another column; a poem opens on the page.
|
||||
if (!terse && entry is CatEntry.Chapter) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = colors.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
excerpt?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (selected) colors.onSecondaryContainer else colors.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The page for an open poet, book or chapter: its description, then what it holds as cards.
|
||||
* The column beside it lists the same things; this is what fills the room the phone's list did.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CategoryOverview(
|
||||
state: BrowserState,
|
||||
fullUrl: String,
|
||||
toggle: @Composable () -> Unit,
|
||||
onUp: () -> Unit,
|
||||
onHome: () -> Unit,
|
||||
onCategory: (String) -> Unit,
|
||||
onPoem: (String) -> Unit,
|
||||
) {
|
||||
BackHandler(onBack = onUp)
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = state.categories[fullUrl]?.title.orEmpty(),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
Row {
|
||||
toggle()
|
||||
IconButton(onClick = onUp) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back))
|
||||
}
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
HomeAction(onHome)
|
||||
poetSlug(fullUrl)?.let { PoetDownloadAction(it) }
|
||||
ReadingSettingsAction()
|
||||
},
|
||||
)
|
||||
}
|
||||
) { insets ->
|
||||
Load(
|
||||
key = fullUrl,
|
||||
block = { state.category(fullUrl) },
|
||||
placeholder = {
|
||||
SkeletonCards(Modifier.padding(top = insets.calculateTopPadding()).padding(horizontal = 12.dp))
|
||||
},
|
||||
) { cat ->
|
||||
LaunchedEffect(cat.id) {
|
||||
if (cat.poems.isNotEmpty() && cat.id !in state.excerpts) {
|
||||
state.excerpts[cat.id] =
|
||||
runCatching { Ganjoor.excerpts(cat.id) }.getOrDefault(emptyMap())
|
||||
}
|
||||
}
|
||||
val entries = remember(cat) { orderedEntries(cat) }
|
||||
val excerpts = state.excerpts[cat.id].orEmpty()
|
||||
|
||||
BoxWithConstraints(Modifier.fillMaxSize().padding(top = insets.calculateTopPadding())) {
|
||||
// The same centred measure as a poem, a little wider for the cards.
|
||||
val side = maxOf(24.dp, (maxWidth - 760.dp) / 2)
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(200.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(
|
||||
start = side,
|
||||
end = side,
|
||||
top = 8.dp,
|
||||
bottom = insets.calculateBottomPadding() + 32.dp,
|
||||
),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
cat.description?.takeIf { it.isNotBlank() }?.let { description ->
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
items(entries) { entry ->
|
||||
Card(
|
||||
onClick = {
|
||||
when (entry) {
|
||||
is CatEntry.Chapter -> onCategory(entry.category.fullUrl)
|
||||
is CatEntry.Poem -> onPoem(entry.poem.fullUrl)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth().padding(12.dp)) {
|
||||
Text(
|
||||
text = when (entry) {
|
||||
is CatEntry.Chapter -> entry.category.title
|
||||
is CatEntry.Poem -> entry.poem.title
|
||||
},
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
(entry as? CatEntry.Poem)?.let { excerpts[it.poem.id] }
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,12 +1,27 @@
|
||||
package com.ganjoor.android.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandHorizontally
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkHorizontally
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
@ -71,6 +86,8 @@ private fun NavController.goUp(fromUrl: String) {
|
||||
fun GanjoorApp() {
|
||||
val nav = rememberNavController()
|
||||
var settingsOpen by rememberSaveable { mutableStateOf(false) }
|
||||
// Above the NavHost, so the large-screen columns survive moving between destinations.
|
||||
val browser = remember { BrowserState() }
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalOpenReadingSettings provides { settingsOpen = true },
|
||||
@ -83,15 +100,40 @@ fun GanjoorApp() {
|
||||
nav.navigate(AssistantRoute)
|
||||
},
|
||||
) {
|
||||
// Tablets and unfolded foldables browse in columns (ColumnBrowser); phones, and a
|
||||
// foldable when folded, keep one screen at a time. Same routes either way, so unfolding
|
||||
// in the middle of a poem keeps the poem.
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
// Read through state everywhere below: the transition and destination lambdas belong to
|
||||
// the nav graph, which outlives this composition, and folding or unfolding must reach them.
|
||||
val wide by rememberUpdatedState(maxWidth >= 600.dp)
|
||||
// Up to three list columns beside the poets; below this, only the newest one.
|
||||
val expanded by rememberUpdatedState(maxWidth >= 840.dp)
|
||||
// On a large screen reading settings open in a panel on the left, where the dictionary
|
||||
// opens, so the page stays in view and shows each change as it is made.
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
// The columns fold away while the settings panel is open, so the page doesn't end up
|
||||
// squeezed between them and the panel.
|
||||
Box(Modifier.weight(1f)) {
|
||||
CompositionLocalProvider(LocalSidePanelOpen provides (wide && settingsOpen)) {
|
||||
NavHost(
|
||||
navController = nav,
|
||||
startDestination = PoetsRoute,
|
||||
// Start/End rather than Left/Right, so going deeper always moves against the reading
|
||||
// direction — leftwards here, since the app lays out right-to-left.
|
||||
enterTransition = { slideIntoContainer(SlideDirection.Start) },
|
||||
exitTransition = { slideOutOfContainer(SlideDirection.Start) },
|
||||
popEnterTransition = { slideIntoContainer(SlideDirection.End) },
|
||||
popExitTransition = { slideOutOfContainer(SlideDirection.End) },
|
||||
// direction — leftwards here, since the app lays out right-to-left. In columns the
|
||||
// page changes in place: sliding the whole screen would drag the columns with it.
|
||||
enterTransition = {
|
||||
if (wide) EnterTransition.None else slideIntoContainer(SlideDirection.Start)
|
||||
},
|
||||
exitTransition = {
|
||||
if (wide) ExitTransition.None else slideOutOfContainer(SlideDirection.Start)
|
||||
},
|
||||
popEnterTransition = {
|
||||
if (wide) EnterTransition.None else slideIntoContainer(SlideDirection.End)
|
||||
},
|
||||
popExitTransition = {
|
||||
if (wide) ExitTransition.None else slideOutOfContainer(SlideDirection.End)
|
||||
},
|
||||
) {
|
||||
composable<PoetsRoute> {
|
||||
PoetsScreen(
|
||||
@ -103,6 +145,27 @@ fun GanjoorApp() {
|
||||
}
|
||||
composable<CategoryRoute> { entry ->
|
||||
val url = entry.toRoute<CategoryRoute>().url
|
||||
if (wide) {
|
||||
ColumnBrowser(
|
||||
state = browser,
|
||||
url = url,
|
||||
isPoem = false,
|
||||
expanded = expanded,
|
||||
onPoet = { nav.open(CategoryRoute(it)) },
|
||||
onCategory = { nav.open(CategoryRoute(it)) },
|
||||
onPoem = { nav.open(PoemRoute(it)) },
|
||||
) { toggle ->
|
||||
CategoryOverview(
|
||||
state = browser,
|
||||
fullUrl = url,
|
||||
toggle = toggle,
|
||||
onUp = { nav.goUp(url) },
|
||||
onHome = { nav.goHome() },
|
||||
onCategory = { nav.open(CategoryRoute(it)) },
|
||||
onPoem = { nav.open(PoemRoute(it)) },
|
||||
)
|
||||
}
|
||||
} else {
|
||||
CategoryScreen(
|
||||
fullUrl = url,
|
||||
onUp = { nav.goUp(url) },
|
||||
@ -111,8 +174,10 @@ fun GanjoorApp() {
|
||||
onPoem = { nav.open(PoemRoute(it)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
composable<PoemRoute> { entry ->
|
||||
val route = entry.toRoute<PoemRoute>()
|
||||
val poem: @Composable (Boolean, (@Composable () -> Unit)?) -> Unit = { wideText, toggle ->
|
||||
PoemScreen(
|
||||
fullUrl = route.url,
|
||||
// Reading on through a divan keeps the origin, so Back still lands where
|
||||
@ -130,8 +195,24 @@ fun GanjoorApp() {
|
||||
},
|
||||
// Tapping a breadcrumb leaves the saved list behind and starts browsing.
|
||||
onCategory = { nav.open(CategoryRoute(it)) },
|
||||
wide = wideText,
|
||||
navigationToggle = toggle,
|
||||
)
|
||||
}
|
||||
if (wide) {
|
||||
ColumnBrowser(
|
||||
state = browser,
|
||||
url = route.url,
|
||||
isPoem = true,
|
||||
expanded = expanded,
|
||||
onPoet = { nav.open(CategoryRoute(it)) },
|
||||
onCategory = { nav.open(CategoryRoute(it)) },
|
||||
onPoem = { nav.open(PoemRoute(it)) },
|
||||
) { toggle -> poem(true, toggle) }
|
||||
} else {
|
||||
poem(false, null)
|
||||
}
|
||||
}
|
||||
composable<SearchRoute> { entry ->
|
||||
SearchScreen(
|
||||
initialTerm = entry.toRoute<SearchRoute>().term,
|
||||
@ -158,8 +239,21 @@ fun GanjoorApp() {
|
||||
AboutScreen(onUp = { nav.navigateUp() }, onHome = { nav.goHome() })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (wide) {
|
||||
AnimatedVisibility(
|
||||
visible = settingsOpen,
|
||||
enter = expandHorizontally() + fadeIn(),
|
||||
exit = shrinkHorizontally() + fadeOut(),
|
||||
) {
|
||||
ReadingSettingsPanel(onDismiss = { settingsOpen = false })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inside the provider: the sheet reads LocalOpenAbout, so it has to be in scope.
|
||||
if (settingsOpen) ReadingSettingsSheet(onDismiss = { settingsOpen = false })
|
||||
if (settingsOpen && !wide) ReadingSettingsSheet(onDismiss = { settingsOpen = false })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,12 +1,17 @@
|
||||
package com.ganjoor.android.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.background
|
||||
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.statusBarsPadding
|
||||
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
|
||||
@ -27,15 +32,36 @@ import com.ganjoor.android.data.NotDownloaded
|
||||
/**
|
||||
* Fetches [block] whenever [key] changes and renders loading / error / content.
|
||||
*
|
||||
* While loading it shows [placeholder]: a skeleton shaped like what is coming, never a spinner
|
||||
* over the whole page, so only the part that is actually waiting looks like it. The content
|
||||
* fades in over it when it arrives.
|
||||
*
|
||||
* ponytail: no ViewModel, so going back re-fetches — which the disk cache makes nearly free.
|
||||
* Promote to a ViewModel when a screen gains state worth surviving rotation.
|
||||
*/
|
||||
@Composable
|
||||
fun <T> Load(key: Any?, block: suspend () -> T, content: @Composable (T) -> Unit) {
|
||||
fun <T> Load(
|
||||
key: Any?,
|
||||
block: suspend () -> T,
|
||||
placeholder: @Composable () -> Unit = { SkeletonList(Modifier.statusBarsPadding()) },
|
||||
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 ->
|
||||
AnimatedContent(
|
||||
targetState = result,
|
||||
transitionSpec = { fadeIn(tween(220)) togetherWith fadeOut(tween(120)) },
|
||||
// Fade between loading, failed and loaded, not on every new value of the same state.
|
||||
contentKey = { outcome ->
|
||||
when {
|
||||
outcome == null -> 0
|
||||
outcome.isFailure -> 1
|
||||
else -> 2
|
||||
}
|
||||
},
|
||||
label = "load",
|
||||
) { outcome ->
|
||||
when {
|
||||
// Painted explicitly: on the category and poem screens Load sits outside the
|
||||
// Scaffold, so while loading nothing else fills the window and the bare window
|
||||
@ -44,9 +70,8 @@ fun <T> Load(key: Any?, block: suspend () -> T, content: @Composable (T) -> Unit
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
placeholder()
|
||||
}
|
||||
|
||||
outcome.isFailure -> Column(
|
||||
|
||||
@ -1,9 +1,22 @@
|
||||
package com.ganjoor.android.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandHorizontally
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkHorizontally
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
@ -47,13 +60,15 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.ganjoor.android.R
|
||||
import com.ganjoor.android.data.Bookmark
|
||||
import com.ganjoor.android.data.breadcrumbs
|
||||
import com.ganjoor.android.data.wordAt
|
||||
import com.ganjoor.android.data.wordRangeAt
|
||||
import com.ganjoor.android.data.Ganjoor
|
||||
import com.ganjoor.android.data.LocalAssistant
|
||||
import com.ganjoor.android.data.LocalBookmarks
|
||||
@ -71,14 +86,40 @@ fun PoemScreen(
|
||||
onHome: () -> Unit,
|
||||
onPoem: (String) -> Unit,
|
||||
onCategory: (String) -> Unit,
|
||||
/** Large screens: the text keeps a centred reading measure and couplets may sit on one line. */
|
||||
wide: Boolean = false,
|
||||
/** Large screens: the button that hides or shows the columns, placed before the back arrow. */
|
||||
navigationToggle: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
BackHandler(onBack = onUp)
|
||||
|
||||
// The couplet travels with the word: the dictionary sheet is the one gesture every reader
|
||||
// finds, so the couplet's own actions live at its foot rather than behind a tap between words.
|
||||
var tapped by remember { mutableStateOf<Pair<String, Bookmark>?>(null) }
|
||||
var tapped by remember { mutableStateOf<WordTap?>(null) }
|
||||
|
||||
Load(key = fullUrl, block = { Ganjoor.poem(fullUrl) }) { poem ->
|
||||
// One side panel at a time. The dictionary and the reading settings both want the left of the
|
||||
// screen, and opening the second put two panels there at once — or, where there was no longer
|
||||
// room for two, left the dictionary as a sheet in the middle of the page while the settings
|
||||
// sat beside it. Either way the reader is asked to look in two places. The settings replace
|
||||
// the dictionary instead; closing them leaves the poem, which is where the reader was.
|
||||
val sidePanelOpen = LocalSidePanelOpen.current
|
||||
LaunchedEffect(sidePanelOpen) { if (sidePanelOpen) tapped = null }
|
||||
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
// Whether the dictionary gets a column of its own is not a question about the window but
|
||||
// about what is left of this page once the columns have taken theirs. On a book-style
|
||||
// foldable held open in portrait the page is already down to ~450dp, and a panel beside it
|
||||
// left the verse a couple of characters a line. Where it does not fit, the sheet is the
|
||||
// better answer: it covers the foot of the poem but leaves the lines whole.
|
||||
val roomForPanel = dictionaryFitsBeside(maxWidth, wide)
|
||||
|
||||
Load(
|
||||
key = fullUrl,
|
||||
block = { Ganjoor.poem(fullUrl) },
|
||||
// The bar stays up with Back, Home and the reading settings already working; only
|
||||
// the text waits. Sharing and bookmarking need a poem, so they arrive with it.
|
||||
placeholder = { LoadingPoem(wide, onUp, onHome, navigationToggle) },
|
||||
) { poem ->
|
||||
val prefs = LocalSettings.current.value
|
||||
val style = readingStyle(prefs.font, prefs.fontSize, prefs.fontWeight.weight)
|
||||
val couplets = remember(poem) { poem.verses.couplets() }
|
||||
@ -93,14 +134,21 @@ fun PoemScreen(
|
||||
}
|
||||
val here = siblings.indexOfFirst { it.fullUrl == fullUrl }
|
||||
|
||||
// On a large screen the dictionary opens beside the poem, on the left, instead of as a
|
||||
// sheet over it: the text moves over to make room and nothing of it is covered.
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
Box(Modifier.weight(1f)) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(poem.title, maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||
navigationIcon = {
|
||||
Row {
|
||||
navigationToggle?.invoke()
|
||||
IconButton(onClick = onUp) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back))
|
||||
}
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
HomeAction(onHome)
|
||||
@ -123,20 +171,30 @@ fun PoemScreen(
|
||||
// here: Compose 1.10 stopped routing SelectionContainer through LocalTextToolbar, so
|
||||
// a custom TextToolbar is never asked to show. ACTION_PROCESS_TEXT goes round that,
|
||||
// and reaches every other app's selection menu as a side effect.
|
||||
SelectionContainer {
|
||||
BoxWithConstraints {
|
||||
// On a large screen the text keeps a reading measure in the middle of whatever room
|
||||
// the columns leave it, and once that measure is wide enough the two hemistichs of a
|
||||
// couplet share a line, as ganjoor.net sets them on a desktop.
|
||||
val side = if (wide) maxOf(20.dp, (maxWidth - 680.dp) / 2) else 20.dp
|
||||
val sideBySide = wide && maxWidth - side * 2 >= 640.dp
|
||||
Column(Modifier.fillMaxSize().padding(top = insets.calculateTopPadding())) {
|
||||
// Above the text, not in it: as an item of the list the player was disposed the
|
||||
// moment it scrolled off, which released the MediaPlayer and cut the reading off
|
||||
// mid-line. Here it keeps playing, and stays in reach while you read further down.
|
||||
RecitationPlayer(poem.id, Modifier.padding(start = side, end = side, top = 8.dp))
|
||||
SelectionContainer(Modifier.weight(1f)) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(
|
||||
start = 20.dp,
|
||||
end = 20.dp,
|
||||
top = insets.calculateTopPadding() + 8.dp,
|
||||
start = side,
|
||||
end = side,
|
||||
top = 8.dp,
|
||||
bottom = insets.calculateBottomPadding() + 32.dp,
|
||||
),
|
||||
) {
|
||||
item {
|
||||
Column(Modifier.padding(bottom = 12.dp)) {
|
||||
Breadcrumbs(poem.fullTitle, poem.fullUrl.ifBlank { fullUrl }, onCategory)
|
||||
RecitationPlayer(poem.id)
|
||||
poem.metre?.rhythm?.let { rhythm ->
|
||||
Text(
|
||||
text = rhythm,
|
||||
@ -151,9 +209,11 @@ fun PoemScreen(
|
||||
Couplet(
|
||||
couplet = couplet,
|
||||
style = style,
|
||||
sideBySide = sideBySide,
|
||||
showSummaries = prefs.showSummaries,
|
||||
source = Bookmark(fullUrl, poem.title, poem.fullTitle),
|
||||
onWord = { word, passage -> tapped = word to passage },
|
||||
onWord = { tapped = it },
|
||||
tapped = tapped,
|
||||
)
|
||||
}
|
||||
|
||||
@ -185,14 +245,79 @@ fun PoemScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tapped?.let { (word, passage) ->
|
||||
WordSheet(
|
||||
word = word,
|
||||
passage = passage,
|
||||
}
|
||||
}
|
||||
if (roomForPanel) {
|
||||
AnimatedVisibility(
|
||||
visible = tapped != null,
|
||||
enter = expandHorizontally() + fadeIn(),
|
||||
exit = shrinkHorizontally() + fadeOut(),
|
||||
) {
|
||||
tapped?.let { tap ->
|
||||
WordPanel(
|
||||
word = tap.word,
|
||||
passage = tap.passage,
|
||||
onDismiss = { tapped = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!roomForPanel) {
|
||||
tapped?.let { tap ->
|
||||
WordSheet(
|
||||
word = tap.word,
|
||||
passage = tap.passage,
|
||||
onDismiss = { tapped = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The positions that make a line of verse; anything else (Single, Paragraph, Comment) is prose. */
|
||||
private val VERSE_POSITIONS = setOf(Verse.RIGHT, Verse.LEFT, Verse.CENTERED_1, Verse.CENTERED_2)
|
||||
|
||||
/** A word someone tapped: what to look up, the couplet it came from, and where it sits in its verse. */
|
||||
private data class WordTap(val word: String, val passage: Bookmark, val verse: Int, val range: IntRange)
|
||||
|
||||
/** The poem screen while its poem is on the way: the real top bar, and the text as a skeleton. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun LoadingPoem(
|
||||
wide: Boolean,
|
||||
onUp: () -> Unit,
|
||||
onHome: () -> Unit,
|
||||
navigationToggle: (@Composable () -> Unit)?,
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {},
|
||||
navigationIcon = {
|
||||
Row {
|
||||
navigationToggle?.invoke()
|
||||
IconButton(onClick = onUp) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back))
|
||||
}
|
||||
}
|
||||
},
|
||||
// Going home and changing the reading settings do not depend on this poem, so
|
||||
// they do not wait for it. Share and bookmark do, and appear when it lands.
|
||||
actions = {
|
||||
HomeAction(onHome)
|
||||
ReadingSettingsAction()
|
||||
},
|
||||
)
|
||||
}
|
||||
) { insets ->
|
||||
BoxWithConstraints(Modifier.fillMaxSize().padding(insets)) {
|
||||
val side = if (wide) maxOf(20.dp, (maxWidth - 680.dp) / 2) else 20.dp
|
||||
SkeletonPoem(Modifier.padding(start = side, end = side, top = 16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The poem's path, with every ancestor tappable: poet » book » section » this poem. */
|
||||
@ -271,22 +396,64 @@ private fun Couplet(
|
||||
style: androidx.compose.ui.text.TextStyle,
|
||||
showSummaries: Boolean,
|
||||
source: Bookmark,
|
||||
onWord: (String, Bookmark) -> Unit,
|
||||
onWord: (WordTap) -> Unit,
|
||||
tapped: WordTap?,
|
||||
sideBySide: Boolean = false,
|
||||
) {
|
||||
// Tap, not long-press: long-press belongs to the text selection this sits inside.
|
||||
var actionsOpen by remember(couplet) { mutableStateOf(false) }
|
||||
val passage = source.copy(excerpt = couplet.joinToString("\n") { it.text })
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||
// Only a true Right+Left pair shares a line; centred verses and prose keep their own.
|
||||
val oneLine = sideBySide && couplet.size == 2 &&
|
||||
couplet[0].position == Verse.RIGHT && couplet[1].position == Verse.LEFT
|
||||
|
||||
// Each line of verse sits in its own soft card, so the eye finds where one couplet ends and
|
||||
// the next begins, and the couplet's actions visibly belong to it. Prose (Golestan,
|
||||
// Nowruznameh) stays bare: a paragraph in a box reads as a quotation, not as the text.
|
||||
val isVerse = couplet.all { it.position in VERSE_POSITIONS }
|
||||
val colors = MaterialTheme.colorScheme
|
||||
// A step lighter than the page. On OLED black the usual step is all but black itself, so
|
||||
// the card takes the next one up: still dim, but there.
|
||||
val cardColor =
|
||||
if (colors.surface == Color.Black) colors.surfaceContainerHighest else colors.surfaceContainerHigh
|
||||
val card = if (isVerse) {
|
||||
Modifier
|
||||
.padding(vertical = 4.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(cardColor)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
} else {
|
||||
Modifier.padding(vertical = 6.dp)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth().then(card)) {
|
||||
if (oneLine) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
couplet.forEachIndexed { index, verse ->
|
||||
if (index > 0) Spacer(Modifier.width(32.dp))
|
||||
VerseText(
|
||||
verse = verse,
|
||||
style = style,
|
||||
onWord = { range -> onWord(tapOf(verse, range, passage)) },
|
||||
onElsewhere = { actionsOpen = !actionsOpen },
|
||||
highlight = tapped?.takeIf { it.verse == verse.vOrder }?.range,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
couplet.forEach { verse ->
|
||||
VerseText(
|
||||
verse = verse,
|
||||
style = style,
|
||||
onWord = { onWord(it, passage) },
|
||||
onWord = { range -> onWord(tapOf(verse, range, passage)) },
|
||||
// A tap that lands between words still opens the couplet's own actions.
|
||||
onElsewhere = { actionsOpen = !actionsOpen },
|
||||
highlight = tapped?.takeIf { it.verse == verse.vOrder }?.range,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (showSummaries) {
|
||||
couplet.firstNotNullOfOrNull { it.coupletSummary }
|
||||
?.takeIf { it.isNotBlank() }
|
||||
@ -346,18 +513,36 @@ private fun Couplet(
|
||||
* One hemistich. Tapping a word looks it up; tapping between words falls through to the
|
||||
* couplet's save and copy actions, so both live on the same gesture without fighting.
|
||||
*/
|
||||
private fun tapOf(verse: Verse, range: IntRange, passage: Bookmark) =
|
||||
WordTap(verse.text.substring(range), passage, verse.vOrder, range)
|
||||
|
||||
@Composable
|
||||
private fun VerseText(
|
||||
verse: Verse,
|
||||
style: androidx.compose.ui.text.TextStyle,
|
||||
onWord: (String) -> Unit,
|
||||
onWord: (IntRange) -> Unit,
|
||||
onElsewhere: () -> Unit,
|
||||
/** The word being looked up, marked so the reader can see which one it was. */
|
||||
highlight: IntRange? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var layout by remember(verse.text) { mutableStateOf<TextLayoutResult?>(null) }
|
||||
val fontSizePx = with(LocalDensity.current) { style.fontSize.toPx() }
|
||||
val mark = SpanStyle(
|
||||
background = MaterialTheme.colorScheme.secondaryContainer,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
val text = remember(verse.text, highlight, mark) {
|
||||
buildAnnotatedString {
|
||||
append(verse.text)
|
||||
if (highlight != null && highlight.last < verse.text.length) {
|
||||
addStyle(mark, highlight.first, highlight.last + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = verse.text,
|
||||
text = text,
|
||||
style = style,
|
||||
textAlign = when (verse.position) {
|
||||
Verse.RIGHT -> TextAlign.Start
|
||||
@ -367,7 +552,7 @@ private fun VerseText(
|
||||
else -> TextAlign.Justify
|
||||
},
|
||||
onTextLayout = { layout = it },
|
||||
modifier = Modifier
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.pointerInput(verse.text) {
|
||||
detectTapGestures { position ->
|
||||
@ -379,7 +564,7 @@ private fun VerseText(
|
||||
}
|
||||
|
||||
/**
|
||||
* The word actually under [position], or null if the tap missed the glyphs.
|
||||
* Where the word actually under [position] lies, or null if the tap missed the glyphs.
|
||||
*
|
||||
* getOffsetForPosition alone isn't enough: nastaliq is set with 2.4x leading, so most of a line
|
||||
* box is empty space above the glyphs, and a tap there clamps to the line's first character —
|
||||
@ -391,7 +576,7 @@ internal fun wordTappedAt(
|
||||
text: String,
|
||||
position: Offset,
|
||||
fontSizePx: Float,
|
||||
): String? {
|
||||
): IntRange? {
|
||||
if (text.isEmpty() || fontSizePx <= 0f) return null
|
||||
val offset = layout.getOffsetForPosition(position).coerceIn(0, text.length - 1)
|
||||
val baseline = layout.getLineBaseline(layout.getLineForOffset(offset))
|
||||
@ -400,7 +585,7 @@ internal fun wordTappedAt(
|
||||
// swapped and taps start feeling off.
|
||||
if (position.y < baseline - fontSizePx * 1.4f) return null
|
||||
if (position.y > baseline + fontSizePx * 0.6f) return null
|
||||
return wordAt(text, offset)
|
||||
return wordRangeAt(text, offset)
|
||||
}
|
||||
|
||||
/** Save this passage, or copy it. Saving keeps the link back to the poem; copying doesn't. */
|
||||
|
||||
@ -99,7 +99,16 @@ fun PoetsScreen(
|
||||
)
|
||||
},
|
||||
) { insets ->
|
||||
Load(key = Unit, block = { Ganjoor.manifest() }) { manifest ->
|
||||
Load(
|
||||
key = Unit,
|
||||
block = { Ganjoor.manifest() },
|
||||
// Cards or rows, whichever the reader chose, while the poets arrive.
|
||||
placeholder = {
|
||||
val top = Modifier.padding(top = insets.calculateTopPadding())
|
||||
if (LocalSettings.current.value.poetGrid) SkeletonCards(top, cards = 8, circle = true)
|
||||
else SkeletonList(top, twoLines = false, leadingCircle = 44.dp)
|
||||
},
|
||||
) { manifest ->
|
||||
val settings = LocalSettings.current
|
||||
val sort = settings.value.poetSort
|
||||
val pinned = LocalPinnedPoets.current
|
||||
@ -109,20 +118,7 @@ fun PoetsScreen(
|
||||
else manifest.poets.filter {
|
||||
it.nickname.contains(query.trim(), ignoreCase = true)
|
||||
}
|
||||
when (sort) {
|
||||
// Persian letters don't sort correctly by code point (آ vs ا, ی vs ي), so hand
|
||||
// the ordering to a collator rather than String.compareTo.
|
||||
PoetSort.Name -> {
|
||||
val collator = Collator.getInstance(Locale.forLanguageTag("fa"))
|
||||
matches.sortedWith { a, b -> collator.compare(a.nickname, b.nickname) }
|
||||
}
|
||||
|
||||
// Pinned first, in the order they were pinned, then the rest untouched. With
|
||||
// nothing pinned this is Ganjoor's order, which is why it can be the default.
|
||||
PoetSort.Pinned -> pinnedFirst(matches, pinned.items)
|
||||
|
||||
PoetSort.Default -> matches
|
||||
}
|
||||
orderPoets(matches, sort, pinned.items)
|
||||
}
|
||||
|
||||
val padding = PaddingValues(
|
||||
@ -377,6 +373,23 @@ private fun PoetCard(poet: PoetRef, pinned: Boolean, onClick: () -> Unit, onPin:
|
||||
}
|
||||
}
|
||||
|
||||
/** The poets in the order [sort] asks for. Shared by the home screen and the large-screen column. */
|
||||
internal fun orderPoets(poets: List<PoetRef>, sort: PoetSort, pins: List<String>): List<PoetRef> =
|
||||
when (sort) {
|
||||
// Persian letters don't sort correctly by code point (آ vs ا, ی vs ي), so hand
|
||||
// the ordering to a collator rather than String.compareTo.
|
||||
PoetSort.Name -> {
|
||||
val collator = Collator.getInstance(Locale.forLanguageTag("fa"))
|
||||
poets.sortedWith { a, b -> collator.compare(a.nickname, b.nickname) }
|
||||
}
|
||||
|
||||
// Pinned first, in the order they were pinned, then the rest untouched. With
|
||||
// nothing pinned this is Ganjoor's order, which is why it can be the default.
|
||||
PoetSort.Pinned -> pinnedFirst(poets, pins)
|
||||
|
||||
PoetSort.Default -> poets
|
||||
}
|
||||
|
||||
/**
|
||||
* Pinned poets first, in the order they were pinned, then everyone else as Ganjoor has them.
|
||||
*
|
||||
|
||||
@ -1,24 +1,32 @@
|
||||
package com.ganjoor.android.ui
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
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.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.Text
|
||||
@ -58,11 +66,53 @@ fun ReadingSettingsAction() {
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ReadingSettingsSheet(onDismiss: () -> Unit) {
|
||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||
ReadingSettingsContent()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The same settings as [ReadingSettingsSheet], as a panel on the left of a large screen — the
|
||||
* place the dictionary opens too. The page stays in view beside it, so a change of theme, font
|
||||
* or size shows on the poem itself as it is made. The cross, or Back, closes it.
|
||||
*/
|
||||
@Composable
|
||||
fun ReadingSettingsPanel(onDismiss: () -> Unit) {
|
||||
BackHandler(onBack = onDismiss)
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
modifier = Modifier.width(360.dp).fillMaxHeight(),
|
||||
) {
|
||||
Column(Modifier.statusBarsPadding()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 56.dp)
|
||||
.padding(start = 20.dp, end = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.reading_settings),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(Icons.Default.Close, stringResource(R.string.close))
|
||||
}
|
||||
}
|
||||
HorizontalDivider(modifier = Modifier.padding(bottom = 8.dp))
|
||||
ReadingSettingsContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReadingSettingsContent() {
|
||||
val settings = LocalSettings.current
|
||||
val prefs = settings.value
|
||||
val context = LocalContext.current
|
||||
|
||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
// Inset first, then scroll: the viewport has to stop above the nav bar,
|
||||
@ -148,7 +198,6 @@ fun ReadingSettingsSheet(onDismiss: () -> Unit) {
|
||||
Text(stringResource(R.string.about))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@ -3,6 +3,7 @@ package com.ganjoor.android.ui
|
||||
import android.media.AudioAttributes
|
||||
import android.media.MediaPlayer
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@ -15,11 +16,14 @@ import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
@ -27,24 +31,40 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import com.ganjoor.android.R
|
||||
import com.ganjoor.android.data.Ganjoor
|
||||
import com.ganjoor.android.data.Recitation
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/** mm:ss, the only shape a reading's length ever needs. */
|
||||
private fun clock(millis: Int): String {
|
||||
val total = (millis / 1000).coerceAtLeast(0)
|
||||
return "%d:%02d".format(total / 60, total % 60)
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays a reading of the poem, streamed from Ganjoor.
|
||||
*
|
||||
* ponytail: the platform's MediaPlayer rather than ExoPlayer — one URL, play and pause, no
|
||||
* playlist or seeking to justify a media library. Nothing is cached, so this is the one part of
|
||||
* the app that needs a connection; it simply doesn't appear when there is no reading or no
|
||||
* network.
|
||||
* Placed above the scrolling text rather than inside it, and deliberately so: as an item of the
|
||||
* LazyColumn it was disposed the moment it scrolled off, which released the MediaPlayer and cut
|
||||
* the reading off mid-line. Pinned here it stays in composition for as long as the poem is open,
|
||||
* and stays in reach — which is what a player is for while you are reading further down.
|
||||
*
|
||||
* ponytail: still the platform's MediaPlayer rather than ExoPlayer. One URL, and seeking within
|
||||
* it, is not a media library's worth of work. Nothing is cached, so this is the one part of the
|
||||
* app that needs a connection; it simply doesn't appear when there is no reading or no network.
|
||||
*/
|
||||
@Composable
|
||||
fun RecitationPlayer(poemId: Int) {
|
||||
fun RecitationPlayer(poemId: Int, modifier: Modifier = Modifier) {
|
||||
val recitations by produceState(emptyList<Recitation>(), poemId) {
|
||||
value = Ganjoor.recitations(poemId)
|
||||
}
|
||||
@ -54,6 +74,10 @@ fun RecitationPlayer(poemId: Int) {
|
||||
var playing by remember(poemId) { mutableStateOf(false) }
|
||||
var loading by remember(poemId) { mutableStateOf(false) }
|
||||
var picking by remember { mutableStateOf(false) }
|
||||
var duration by remember(poemId) { mutableIntStateOf(0) }
|
||||
var position by remember(poemId) { mutableIntStateOf(0) }
|
||||
// While a finger is on the slider the poll must not fight it for the handle.
|
||||
var scrubbing by remember(poemId) { mutableStateOf<Float?>(null) }
|
||||
|
||||
val player = remember {
|
||||
MediaPlayer().apply {
|
||||
@ -65,7 +89,7 @@ fun RecitationPlayer(poemId: Int) {
|
||||
)
|
||||
}
|
||||
}
|
||||
// A reading left playing when the screen goes would keep the whole poem in memory.
|
||||
// A reading left playing when the poem closes would keep the whole thing in memory.
|
||||
DisposableEffect(player) { onDispose { runCatching { player.release() } } }
|
||||
|
||||
val recitation = recitations.getOrNull(chosen) ?: return
|
||||
@ -74,16 +98,26 @@ fun RecitationPlayer(poemId: Int) {
|
||||
runCatching { player.reset() }
|
||||
playing = false
|
||||
loading = false
|
||||
position = 0
|
||||
duration = 0
|
||||
onDispose { }
|
||||
}
|
||||
|
||||
// The handle follows the audio only while it is actually moving.
|
||||
LaunchedEffect(playing) {
|
||||
while (playing) {
|
||||
runCatching { position = player.currentPosition }
|
||||
delay(250)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggle() {
|
||||
if (playing) {
|
||||
runCatching { player.pause() }
|
||||
playing = false
|
||||
return
|
||||
}
|
||||
if (player.currentPosition > 0) {
|
||||
if (duration > 0) {
|
||||
runCatching { player.start() }.onSuccess { playing = true }
|
||||
return
|
||||
}
|
||||
@ -91,15 +125,21 @@ fun RecitationPlayer(poemId: Int) {
|
||||
runCatching {
|
||||
player.reset()
|
||||
player.setDataSource(recitation.mp3Url)
|
||||
player.setOnPreparedListener { it.start(); playing = true; loading = false }
|
||||
player.setOnCompletionListener { playing = false }
|
||||
player.setOnPreparedListener {
|
||||
duration = it.duration
|
||||
it.start()
|
||||
playing = true
|
||||
loading = false
|
||||
}
|
||||
player.setOnCompletionListener { playing = false; position = duration }
|
||||
player.setOnErrorListener { _, _, _ -> playing = false; loading = false; true }
|
||||
player.prepareAsync()
|
||||
}.onFailure { loading = false }
|
||||
}
|
||||
|
||||
Column(modifier.fillMaxWidth().padding(bottom = 8.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
@ -138,4 +178,44 @@ fun RecitationPlayer(poemId: Int) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only once the length is known: a bar that cannot be dragged anywhere is furniture.
|
||||
if (duration > 0) {
|
||||
// Time runs left to right whatever the script, so the bar and its two times are laid
|
||||
// out that way inside an otherwise right-to-left page.
|
||||
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = clock(scrubbing?.toInt() ?: position),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Slider(
|
||||
value = scrubbing ?: position.toFloat().coerceIn(0f, duration.toFloat()),
|
||||
onValueChange = { scrubbing = it },
|
||||
onValueChangeFinished = {
|
||||
scrubbing?.let { target ->
|
||||
runCatching { player.seekTo(target.toInt()) }
|
||||
position = target.toInt()
|
||||
}
|
||||
scrubbing = null
|
||||
},
|
||||
valueRange = 0f..duration.toFloat(),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.semantics { contentDescription = "seek" },
|
||||
)
|
||||
Text(
|
||||
text = clock(duration),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -65,6 +65,8 @@ data class Prefs(
|
||||
val oled: Boolean = false,
|
||||
val poetSort: PoetSort = PoetSort.Pinned,
|
||||
val poetGrid: Boolean = true,
|
||||
/** On a large screen, whether the poet and book columns are folded away to leave the poem. */
|
||||
val columnsHidden: Boolean = false,
|
||||
)
|
||||
|
||||
/** Reading preferences, kept in SharedPreferences and read once at startup. */
|
||||
@ -84,6 +86,7 @@ class Settings(context: Context) {
|
||||
oled = prefs.getBoolean("oled", prefs.getString("theme", null) == "Black"),
|
||||
poetSort = enumOrDefault(prefs.getString("poetSort", null), PoetSort.Pinned),
|
||||
poetGrid = prefs.getBoolean("poetGrid", true),
|
||||
columnsHidden = prefs.getBoolean("columnsHidden", false),
|
||||
)
|
||||
)
|
||||
private set
|
||||
@ -103,6 +106,7 @@ class Settings(context: Context) {
|
||||
putBoolean("oled", p.oled)
|
||||
putString("poetSort", p.poetSort.name)
|
||||
putBoolean("poetGrid", p.poetGrid)
|
||||
putBoolean("columnsHidden", p.columnsHidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
147
app/src/main/java/com/ganjoor/android/ui/Skeleton.kt
Normal file
@ -0,0 +1,147 @@
|
||||
package com.ganjoor.android.ui
|
||||
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/*
|
||||
* Placeholders shaped like what is about to arrive, so a part of the screen that is still loading
|
||||
* looks like that part and nothing else does. Everything already on screen stays put around it.
|
||||
*/
|
||||
|
||||
/** Widths for placeholder lines, varied so a list of them reads as text rather than as stripes. */
|
||||
private val WIDTHS = listOf(0.82f, 0.64f, 0.9f, 0.7f, 0.76f, 0.58f, 0.86f, 0.68f)
|
||||
|
||||
/** One slow pulse shared by every shape in a placeholder, so they breathe together. */
|
||||
@Composable
|
||||
private fun pulse(): Float {
|
||||
val alpha by rememberInfiniteTransition(label = "skeleton").animateFloat(
|
||||
initialValue = 0.45f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(tween(900), RepeatMode.Reverse),
|
||||
label = "skeleton alpha",
|
||||
)
|
||||
return alpha
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Bone(modifier: Modifier, alpha: Float, shape: Shape = RoundedCornerShape(6.dp)) {
|
||||
Box(
|
||||
modifier
|
||||
.graphicsLayer { this.alpha = alpha }
|
||||
.clip(shape)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
|
||||
)
|
||||
}
|
||||
|
||||
/** Rows of a list: a title line and, with [twoLines], a shorter line under it. */
|
||||
@Composable
|
||||
fun SkeletonList(
|
||||
modifier: Modifier = Modifier,
|
||||
rows: Int = 8,
|
||||
twoLines: Boolean = true,
|
||||
leadingCircle: Dp? = null,
|
||||
) {
|
||||
val alpha = pulse()
|
||||
Column(modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(20.dp)) {
|
||||
repeat(rows) { i ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (leadingCircle != null) {
|
||||
Bone(Modifier.size(leadingCircle), alpha, CircleShape)
|
||||
Box(Modifier.size(16.dp))
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Bone(Modifier.fillMaxWidth(WIDTHS[i % WIDTHS.size]).height(14.dp), alpha)
|
||||
if (twoLines) {
|
||||
Bone(Modifier.fillMaxWidth(WIDTHS[(i + 3) % WIDTHS.size] * 0.7f).height(10.dp), alpha)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A poem on its way: a breadcrumb line, then couplets, the first half to the start, the second to the end. */
|
||||
@Composable
|
||||
fun SkeletonPoem(modifier: Modifier = Modifier, couplets: Int = 6) {
|
||||
val alpha = pulse()
|
||||
Column(modifier, verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Bone(Modifier.fillMaxWidth(0.5f).height(14.dp), alpha)
|
||||
Box(Modifier.height(8.dp))
|
||||
repeat(couplets) { i ->
|
||||
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Bone(Modifier.fillMaxWidth(WIDTHS[i % WIDTHS.size]).height(18.dp), alpha)
|
||||
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) {
|
||||
Bone(Modifier.fillMaxWidth(WIDTHS[(i + 2) % WIDTHS.size]).height(18.dp), alpha)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Cards on their way, for the poet grid ([circle] portraits) or a book's contents. */
|
||||
@Composable
|
||||
fun SkeletonCards(modifier: Modifier = Modifier, cards: Int = 6, circle: Boolean = false) {
|
||||
val alpha = pulse()
|
||||
Column(modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
repeat((cards + 1) / 2) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
repeat(2) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.padding(12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
if (circle) Bone(Modifier.size(84.dp), alpha, CircleShape)
|
||||
Bone(Modifier.fillMaxWidth(0.6f).height(14.dp), alpha)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The poets column on its way: portrait discs over short names. */
|
||||
@Composable
|
||||
fun SkeletonRail(modifier: Modifier = Modifier, disc: Dp = 56.dp) {
|
||||
val alpha = pulse()
|
||||
Column(
|
||||
modifier.fillMaxSize().padding(vertical = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
repeat(8) {
|
||||
Bone(Modifier.size(disc), alpha, CircleShape)
|
||||
Bone(Modifier.fillMaxWidth(0.5f).aspectRatio(5f), alpha)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,10 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@ -13,10 +17,16 @@ import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.SuggestionChip
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
@ -29,6 +39,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.ganjoor.android.R
|
||||
import com.ganjoor.android.data.Definition
|
||||
@ -38,6 +49,37 @@ import com.ganjoor.android.data.LocalAssistant
|
||||
import com.ganjoor.android.data.Pronunciation
|
||||
import com.ganjoor.android.ui.theme.readingStyle
|
||||
|
||||
/**
|
||||
* How wide the dictionary sits beside the poem.
|
||||
*
|
||||
* Narrower than the reading settings, deliberately: the settings fold the columns away and take
|
||||
* the room that frees, while the dictionary is read *against* the line it came from, so the verse
|
||||
* keeps the width instead. A definition is short; a hemistich is not.
|
||||
*/
|
||||
val DictionaryPanelWidth = 216.dp
|
||||
|
||||
/**
|
||||
* The narrowest the page may be left once the dictionary opens beside it. Below this the panel is
|
||||
* not worth its room: on a book-style foldable held open in portrait the columns have already
|
||||
* taken theirs, and a panel there left the verse a couple of characters a line.
|
||||
*/
|
||||
val MinPageMeasure = 400.dp
|
||||
|
||||
/**
|
||||
* Whether the dictionary earns a column of its own beside a page [pageWidth] wide.
|
||||
*
|
||||
* Not a question about the window but about the page: by the time a poem is open the columns have
|
||||
* already taken their room, and on a book-style foldable held open in portrait what is left is
|
||||
* about 450dp. A panel beside that leaves the verse a couple of characters a line, so below
|
||||
* [MinPageMeasure] the bottom sheet is used instead — it covers the foot of the poem but leaves
|
||||
* every line whole.
|
||||
*
|
||||
* A pure function so the rule can be tested: the gesture that reaches it cannot be driven
|
||||
* reliably, but the arithmetic behind it can.
|
||||
*/
|
||||
internal fun dictionaryFitsBeside(pageWidth: Dp, wide: Boolean): Boolean =
|
||||
wide && pageWidth - DictionaryPanelWidth >= MinPageMeasure
|
||||
|
||||
/** English prose inside an otherwise right-to-left sheet. */
|
||||
@Composable
|
||||
private fun LeftToRight(content: @Composable () -> Unit) {
|
||||
@ -78,8 +120,50 @@ fun WordSheet(word: String, onDismiss: () -> Unit, passage: Bookmark? = null) {
|
||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||
// Inside the lookup's own scroll, not after it: the lookup scrolls, so anything placed
|
||||
// below it is pushed past the bottom of the sheet with no way to reach it.
|
||||
WordLookup(word) {
|
||||
if (passage == null) return@WordLookup
|
||||
WordLookup(word) { CoupletFooter(passage) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The same lookup as [WordSheet], as a panel beside the poem on a large screen. It sits on the
|
||||
* left, away from where the lines start, and the poem moves over for it rather than being
|
||||
* covered; the cross, or Back, closes it.
|
||||
*/
|
||||
@Composable
|
||||
fun WordPanel(word: String, onDismiss: () -> Unit, passage: Bookmark? = null) {
|
||||
BackHandler(onBack = onDismiss)
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
modifier = Modifier.width(DictionaryPanelWidth).fillMaxHeight(),
|
||||
) {
|
||||
Column(Modifier.statusBarsPadding()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 56.dp)
|
||||
.padding(start = 20.dp, end = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.dictionary),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(Icons.Default.Close, stringResource(R.string.close))
|
||||
}
|
||||
}
|
||||
HorizontalDivider(modifier = Modifier.padding(bottom = 12.dp))
|
||||
WordLookup(word) { CoupletFooter(passage) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The couplet's own actions, under the definitions, when the word came from a couplet. */
|
||||
@Composable
|
||||
private fun CoupletFooter(passage: Bookmark?) {
|
||||
if (passage == null) return
|
||||
HorizontalDivider(modifier = Modifier.padding(top = 8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.this_couplet),
|
||||
@ -95,8 +179,6 @@ fun WordSheet(word: String, onDismiss: () -> Unit, passage: Bookmark? = null) {
|
||||
instruction = R.string.assistant_ask_prompt,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
package com.ganjoor.android.ui.theme
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.ExperimentalTextApi
|
||||
import androidx.compose.ui.text.PlatformTextStyle
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontVariation
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
@ -15,24 +15,40 @@ import com.ganjoor.android.R
|
||||
import com.ganjoor.android.ui.ReadingFont
|
||||
|
||||
/**
|
||||
* Both files are variable fonts with a `wght` axis, registered at four weights so a reader can
|
||||
* thicken the text — thin naskh strokes wash out on a lit screen, especially in the dark themes.
|
||||
* Both files are variable fonts, registered at four weights so a reader can thicken the text —
|
||||
* thin naskh strokes wash out on a lit screen, especially in the dark themes.
|
||||
*
|
||||
* Real axis interpolation needs API 26+; on 24 and 25 the variation settings are ignored and
|
||||
* Android falls back to synthesising the heavier weights, which still darkens the text.
|
||||
* Only `wght` is asked for, and deliberately: both files carry that one axis and nothing else.
|
||||
* `FontVariation.Settings(weight, style)` would also send `ital`, and a font with no italic axis
|
||||
* is entitled to refuse the whole request rather than the part it cannot honour. A platform that
|
||||
* does so leaves every weight drawing at 400 — and silently, because each entry here declares the
|
||||
* weight it was asked for, so the text is never a candidate for synthetic bolding either.
|
||||
*/
|
||||
@OptIn(ExperimentalTextApi::class)
|
||||
private fun variable(resId: Int, weight: FontWeight) = Font(
|
||||
resId = resId,
|
||||
weight = weight,
|
||||
variationSettings = FontVariation.Settings(weight, FontStyle.Normal),
|
||||
variationSettings = FontVariation.Settings(FontVariation.weight(weight.weight)),
|
||||
)
|
||||
|
||||
private val weights =
|
||||
listOf(FontWeight.Normal, FontWeight.Medium, FontWeight.SemiBold, FontWeight.Bold)
|
||||
|
||||
val Naskh = FontFamily(weights.map { variable(R.font.noto_naskh_arabic, it) })
|
||||
val Nastaliq = FontFamily(weights.map { variable(R.font.noto_nastaliq_urdu, it) })
|
||||
/**
|
||||
* Variable axes need API 26. Below that the settings above are dropped, and declaring four
|
||||
* weights of the same file would make the heavier ones unreachable: Android would match the
|
||||
* entry claiming 700, draw it at 400, and skip synthetic bolding because the entry said it was
|
||||
* already bold. One entry at its real weight instead, so asking for bold actually synthesises it.
|
||||
*/
|
||||
private fun scriptFamily(resId: Int): FontFamily =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
FontFamily(weights.map { variable(resId, it) })
|
||||
} else {
|
||||
FontFamily(Font(resId, FontWeight.Normal))
|
||||
}
|
||||
|
||||
val Naskh = scriptFamily(R.font.noto_naskh_arabic)
|
||||
val Nastaliq = scriptFamily(R.font.noto_nastaliq_urdu)
|
||||
|
||||
/**
|
||||
* The English UI only. Libron is a reading serif (OFL, github.com/nicoverbruggen/libron) and
|
||||
|
||||
12
app/src/main/res/drawable/ic_menu_open.xml
Normal file
@ -0,0 +1,12 @@
|
||||
<!-- Material "menu_open", for folding the large-screen columns away. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:autoMirrored="true"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?android:attr/textColorPrimary">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M3,18h13v-2H3v2zM3,13h10v-2H3v2zM3,6v2h13V6H3zM21,15.59L17.42,12 21,8.41 19.59,7l-5,5 5,5L21,15.59z" />
|
||||
</vector>
|
||||
@ -115,4 +115,8 @@
|
||||
<string name="view_grid">نمایش کارتی</string>
|
||||
<string name="view_list">نمایش فهرستی</string>
|
||||
<string name="downloaded_poet">پیشتر دریافت شده</string>
|
||||
<!-- Large screens: folds the poet and book columns away to leave the poem. -->
|
||||
<string name="hide_columns">پنهان کردن فهرست</string>
|
||||
<string name="show_columns">نمایش فهرست</string>
|
||||
<string name="close">بستن</string>
|
||||
</resources>
|
||||
|
||||
@ -115,4 +115,8 @@
|
||||
<string name="view_grid">کارڈ کی صورت</string>
|
||||
<string name="view_list">فہرست کی صورت</string>
|
||||
<string name="downloaded_poet">پہلے سے محفوظ ہے</string>
|
||||
<!-- Large screens: folds the poet and book columns away to leave the poem. -->
|
||||
<string name="hide_columns">فہرست چھپائیں</string>
|
||||
<string name="show_columns">فہرست دکھائیں</string>
|
||||
<string name="close">بند کریں</string>
|
||||
</resources>
|
||||
|
||||
@ -120,4 +120,8 @@
|
||||
<string name="view_grid">Show as cards</string>
|
||||
<string name="view_list">Show as a list</string>
|
||||
<string name="downloaded_poet">Already downloaded</string>
|
||||
<!-- Large screens: folds the poet and book columns away to leave the poem. -->
|
||||
<string name="hide_columns">Hide the list</string>
|
||||
<string name="show_columns">Show the list</string>
|
||||
<string name="close">Close</string>
|
||||
</resources>
|
||||
|
||||
34
app/src/test/java/com/ganjoor/android/ColumnUrlsTest.kt
Normal file
@ -0,0 +1,34 @@
|
||||
package com.ganjoor.android
|
||||
|
||||
import com.ganjoor.android.ui.columnUrls
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class ColumnUrlsTest {
|
||||
|
||||
/** A poem four levels down gets a column for each category above it, but none of its own. */
|
||||
@Test
|
||||
fun `a poem lists every category above it`() {
|
||||
assertEquals(
|
||||
listOf("/saadi", "/saadi/golestan", "/saadi/golestan/bab1"),
|
||||
columnUrls("/saadi/golestan/bab1/sh1", isPoem = true),
|
||||
)
|
||||
}
|
||||
|
||||
/** An open category is listed in its own column, with nothing in it chosen yet. */
|
||||
@Test
|
||||
fun `a category gets its own column`() {
|
||||
assertEquals(listOf("/hafez", "/hafez/ghazal"), columnUrls("/hafez/ghazal", isPoem = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a poet is one column`() {
|
||||
assertEquals(listOf("/hafez"), columnUrls("/hafez/", isPoem = false))
|
||||
}
|
||||
|
||||
/** A poem straight under a poet has only the poet's column beside it. */
|
||||
@Test
|
||||
fun `a poem at a poet's root`() {
|
||||
assertEquals(listOf("/khayyam"), columnUrls("/khayyam/sh1", isPoem = true))
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,41 @@
|
||||
package com.ganjoor.android
|
||||
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.ganjoor.android.ui.dictionaryFitsBeside
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The rule that decides whether the dictionary opens beside the poem or as a sheet over it.
|
||||
*
|
||||
* Driving the gesture that reaches it — a tap landing on the glyphs of one Persian word — is not
|
||||
* something adb does reliably, so the arithmetic is tested directly instead.
|
||||
*/
|
||||
class DictionaryPanelFitTest {
|
||||
|
||||
@Test
|
||||
fun `a tablet has room beside the poem`() {
|
||||
// 1280dp window, less the poets rail and two list columns, leaves the page about 900dp.
|
||||
assertTrue(dictionaryFitsBeside(900.dp, wide = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a foldable open in portrait does not`() {
|
||||
// 700dp window, less the rail and the newest column, leaves about 450dp — and 450 less
|
||||
// the 216dp panel is 234dp, which is a cramped phone column, not a measure for a verse.
|
||||
assertFalse(dictionaryFitsBeside(450.dp, wide = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a phone never gets the panel, however the page is measured`() {
|
||||
assertFalse(dictionaryFitsBeside(420.dp, wide = false))
|
||||
assertFalse(dictionaryFitsBeside(2000.dp, wide = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the boundary is the panel plus the minimum measure`() {
|
||||
assertTrue(dictionaryFitsBeside(616.dp, wide = true)) // 400 + 216, exactly
|
||||
assertFalse(dictionaryFitsBeside(615.dp, wide = true))
|
||||
}
|
||||
}
|
||||
@ -134,4 +134,35 @@ class LetterOverlapTest {
|
||||
fun `an empty word never matches`() {
|
||||
assertEquals(0f, letterOverlap("", "عشق"), 0.001f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an arabic comma is not part of the word before it`() {
|
||||
val line = "دل میرود ز دستم، صاحبدلان خدا را"
|
||||
assertEquals("دستم", wordAt(line, line.indexOf("دستم") + 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a question mark is not part of the word before it`() {
|
||||
val line = "صلاح کار کجا و من خراب کجا؟"
|
||||
assertEquals("کجا", wordAt(line, line.lastIndexOf("کجا") + 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a full stop and a semicolon do not join the word`() {
|
||||
assertEquals("یافت", wordAt("نخواهی یافت۔", 8))
|
||||
assertEquals("برخیز", wordAt("شرطه برخیز؛ باد", 7))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `digits are not part of a word`() {
|
||||
assertEquals("غزل", wordAt("غزل۳", 1))
|
||||
assertEquals("غزل", wordAt("غزل١٢", 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `harakat keep a word whole`() {
|
||||
// منِ is one word: the kasra sits inside it, and normalise strips it before the lookup.
|
||||
val line = "و منِ خراب"
|
||||
assertEquals("منِ", wordAt(line, line.indexOf("من") + 1))
|
||||
}
|
||||
}
|
||||
|
||||
78
app/src/test/java/com/ganjoor/android/ReleaseDocsTest.kt
Normal file
@ -0,0 +1,78 @@
|
||||
package com.ganjoor.android
|
||||
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The download the README offers must be the release the build actually produces.
|
||||
*
|
||||
* A README that advertises last version is worse than one that says nothing: someone follows the
|
||||
* link, installs an old build, and has no way to know. tools/update_release_docs.py keeps these in
|
||||
* step; this is what makes forgetting to run it a failed build rather than a quiet wrong answer.
|
||||
*/
|
||||
class ReleaseDocsTest {
|
||||
|
||||
private val root: File =
|
||||
generateSequence(File(System.getProperty("user.dir")!!)) { it.parentFile }
|
||||
.first { File(it, "settings.gradle.kts").exists() }
|
||||
|
||||
private fun read(path: String) = File(root, path).readText()
|
||||
|
||||
private fun sha256(file: File): String =
|
||||
MessageDigest.getInstance("SHA-256").digest(file.readBytes())
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
|
||||
private val declaredVersion: String by lazy {
|
||||
Regex("versionName\\s*=\\s*\"([^\"]+)\"")
|
||||
.find(read("app/build.gradle.kts"))!!
|
||||
.groupValues[1]
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the README offers the version the build declares`() {
|
||||
val offered = Regex("### . \\[Download ganjoor-([^\\]]+)\\.apk]")
|
||||
.find(read("README.md"))
|
||||
?.groupValues?.get(1)
|
||||
assertEquals(
|
||||
"README.md offers a different version than app/build.gradle.kts declares — " +
|
||||
"run tools/update_release_docs.py",
|
||||
declaredVersion,
|
||||
offered,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the APK the README links to is archived`() {
|
||||
val apk = File(root, "releases/ganjoor-" + declaredVersion + ".apk")
|
||||
assertTrue(apk.name + " is linked from README.md but not in releases/", apk.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the README short checksum matches the archived APK`() {
|
||||
val short = Regex("`sha256 ([0-9a-f]+)…([0-9a-f]+)`")
|
||||
.find(read("README.md"))!!
|
||||
.groupValues
|
||||
val full = sha256(File(root, "releases/ganjoor-" + declaredVersion + ".apk"))
|
||||
assertTrue(
|
||||
"README.md's checksum does not match releases/ganjoor-" + declaredVersion + ".apk",
|
||||
full.startsWith(short[1]) && full.endsWith(short[2]),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every checksum in releases matches the APK beside it`() {
|
||||
val listed = Regex("^([0-9a-f]{64}) (ganjoor-.+\\.apk)$", RegexOption.MULTILINE)
|
||||
.findAll(read("releases/README.md"))
|
||||
.map { it.groupValues[1] to it.groupValues[2] }
|
||||
.toList()
|
||||
assertTrue("no checksums found in releases/README.md", listed.isNotEmpty())
|
||||
listed.forEach { (digest, name) ->
|
||||
val apk = File(root, "releases/" + name)
|
||||
assertTrue(name + " is listed in releases/README.md but missing", apk.exists())
|
||||
assertEquals(name + " does not match its listed checksum", digest, sha256(apk))
|
||||
}
|
||||
}
|
||||
}
|
||||
63
design/README.md
Normal file
@ -0,0 +1,63 @@
|
||||
# Ganjoor design system
|
||||
|
||||
This folder holds the app's design system, extracted from its code:
|
||||
- `tokens.json`: colours for all six themes, type styles, spacing, radii and sizes.
|
||||
- `components/`: usage guidelines for each component, with a static preview.
|
||||
- `assets/`: the logo and icons as SVG.
|
||||
- `wireframes/`: the tablet and foldable layout, state by state.
|
||||
|
||||
The font files referenced in `tokens.json` (`fonts/…`) are the app's own, in `app/src/main/res/font/`, and the store screenshots are in `fastlane/metadata/android/fa/images/`. The previews use CSS variables named after the tokens. They render in the published design system artifact, which generates those variables from `tokens.json`.
|
||||
|
||||
---
|
||||
|
||||
Ganjoor is a quiet, long-form reader for Persian poetry, made for Persian, Urdu and Arabic script. The poem is the interface: everything else gets out of its way, says plainly what it does, and works offline. The palette comes from Persian tilework, turquoise with saffron accents, alongside warm paper tones for long sessions.
|
||||
|
||||
Built on Material 3 (Jetpack Compose). Tokens follow the Material colour roles one to one, so `primary` here is `MaterialTheme.colorScheme.primary` in the app.
|
||||
|
||||
## Content fundamentals
|
||||
|
||||
- **Persian first.** The interface defaults to Persian whatever the phone's locale, and also speaks Urdu and English. The app is called **گنجور** in every language. Languages are named in their own script (فارسی · اردو · English).
|
||||
- **Right-to-left, always.** The whole interface lays out and navigates RTL, even when the UI language is English. Directional icons mirror.
|
||||
- **Plain, specific, honest.** Say what happened and what to do next, without blaming the reader: "This isn't downloaded. Turn off offline mode, or download it first." is right; "Network error" is not. A setting's note says when it applies: "Applies to the dark themes; saves power on OLED screens".
|
||||
- **Sentence case, no exclamation marks, no emoji.** Labels are short verbs or nouns: *Save this passage*, *Copy*, *Share*, *Download every poet*. A state replaces the verb: *Saved*.
|
||||
- **Credit and disclose.** AI text is always labelled: "AI-generated by Ganjoor, not by the poet". The app is unofficial and says so in About. It is never presented as ganjoor.net's own.
|
||||
- **Give the reader something useful.** Listings show each poem's opening line under its title, because "Ghazal 237" tells you nothing.
|
||||
|
||||
## Visual foundations
|
||||
|
||||
### Colour and themes
|
||||
Six themes, all the same roles. **Light** and **Dark** use turquoise `primary` with saffron `secondary`. **Sepia** and **Sepia night** are aged-paper schemes with walnut `primary`; the night one is dark without a blue cast. **OLED black** isn't a separate palette. It is a flag on either dark theme that moves only the surfaces towards black (in Oklab) and leaves text and accents exactly as they are. That gives the two themes *Dark · OLED black* and *Sepia night · OLED black*.
|
||||
|
||||
- Page ground is `surface`. Primary text is `on-surface`; secondary text and icons are `on-surface-variant`.
|
||||
- `primary` is for what you can tap or what is active: tappable breadcrumbs, TextButtons, section labels, a saved bookmark, the pin mark. It is never used for running text.
|
||||
- Selected chips and portrait discs use `secondary-container` with `on-secondary-container`.
|
||||
- Poet cards use `surface-container-highest`; sheets use `surface-container-low`; the bottom search bar uses `surface-tonal-3`.
|
||||
- `downloaded` (green) appears only with the check-circle, so the state is never shown by colour alone.
|
||||
- Every text pair named in a token's notes meets 4.5:1 in all six themes. Sepia's `outline` is 3.3:1 on `surface`, which passes for borders only.
|
||||
|
||||
### Typography
|
||||
- **Poems are always Noto Naskh Arabic or Noto Nastaliq Urdu**, whatever the UI language. Use `poem-naskh` by default and `poem-nastaliq` when the reader picks it. The reader chooses a size from 14 to 40px (default 22) and a weight of Regular, Medium, Semibold or Bold, because thin naskh strokes wash out on a lit screen, especially in dark themes. Leading is 1.8× for naskh and 2.4× for nastaliq, whose diagonal stacking and deep descenders would otherwise clip.
|
||||
- **The Persian and Urdu interface** uses the Material 3 type scale in Naskh (`title-medium`, `body-small`…).
|
||||
- **The English interface** uses the same scale in **Libron**, a reading serif (`en-title-medium`…). Libron never sets a poem.
|
||||
|
||||
### Layout and spacing
|
||||
Values are the Compose dp values written in the code (1dp = 1px): `space-20` for the reader column and settings sheet sides, `space-12` for list sides and grid gutters, `space-8` as the default gap, and `space-6` above and below each couplet. Touch targets are `touch` (48px). The poet grid is adaptive with columns of at least `poet-grid-min`.
|
||||
|
||||
**Couplets** stack on a phone: the first hemistich aligns to the start, the second to the end. Prose (Golestan, Nowruznameh) is justified. On large screens the hemistichs sit side by side (see **ColumnBrowser**).
|
||||
|
||||
**One-handed reach:** the poets search lives in a bottom bar, and the settings open as a bottom sheet.
|
||||
|
||||
### Shape, elevation, motion
|
||||
Shapes are Material's: `radius-sm` for chips, `radius-md` for cards, `radius-xl` for sheet tops and `radius-full` for buttons and portrait discs. There are no drop shadows. Elevation is tonal: surface containers step up in tone. Motion is Material's default. The one custom transition is ColumnBrowser's column slide, 400ms emphasized-decelerate.
|
||||
|
||||
### States
|
||||
Pressed and hovered states use Material's state layers. A focus ring is 2px `primary` with a 2px offset, at least 3:1 on every surface. Show a selection with a fill (a chip or the selected pane row) as well as a check mark or a change of word.
|
||||
|
||||
### Imagery
|
||||
Poet portraits come from Ganjoor and are cropped into a circle on `secondary-container`. The poet's initial sits underneath, so a missing portrait never leaves a hole. There are no other illustrations.
|
||||
|
||||
## Iconography
|
||||
Material Icons in the filled style, at 24px, tinted with colour roles (see the Icons group). Use the standard Android share glyph wherever there is sharing, because people recognise it before reading the label. The brand mark is the eight-pointed khatam star (see Logos). There is no wordmark: گنجور is set in Naskh.
|
||||
|
||||
## Large screens
|
||||
Tablets and unfolded foldables use **ColumnBrowser**. Tapping a poet collapses the home grid into a narrow poets column on the far right (portrait with the name underneath). To its left come the poet's books, then the chapters or poems of the open book, each column scrolling on its own. Older columns narrow as new ones open, so the poem stays near the middle, and a top-bar button hides the columns when the reader wants to focus on the poem. The dictionary and reading settings open in a panel on the left, beside the poem rather than over it. Phones are unchanged.
|
||||
16
design/assets/Icons/README.md
Normal file
@ -0,0 +1,16 @@
|
||||
# Icons
|
||||
|
||||
The app uses **Material Icons, filled style**: `Icons.Default.*` from Compose for back (auto-mirrored), home, search, settings, share, favourite or favourite-border, check-circle and the keyboard arrows. It also keeps the vector drawables below, which are copies of Material symbols for glyphs Compose's core set lacks.
|
||||
|
||||
All of them are single-ink SVGs drawn in ink `#191c1c` (light `on-surface`). In the app they are tinted at runtime: actions in `on-surface-variant`, the toggled state in `primary`, and the download tick in `downloaded`. When you use these files in `<img>`, swap the fill for the colour you need.
|
||||
|
||||
| File | Material name | Where |
|
||||
|---|---|---|
|
||||
| ic-download.svg | download | Downloads in the top bar; download a poet |
|
||||
| ic-pin.svg | push_pin | Pin mark on a pinned poet, 16px |
|
||||
| ic-view-grid.svg / ic-view-list.svg | grid_view / view_list | Poets view toggle |
|
||||
| ic-language.svg | language | UI language switcher |
|
||||
| ic-share.svg | share | Share a poem or passage |
|
||||
| ic-lookup.svg | search | Dictionary entry in the selection menu |
|
||||
| ic-ask.svg | auto_awesome | Ask an assistant |
|
||||
| ic-pause.svg | pause | Recitation player |
|
||||
1
design/assets/Icons/ic-ask.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#191c1c" d="M19,9l1.25,-2.75L23,5l-2.75,-1.25L19,1l-1.25,2.75L15,5l2.75,1.25L19,9zM11.5,9.5L9,4 6.5,9.5 1,12l5.5,2.5L9,20l2.5,-5.5L17,12l-5.5,-2.5zM19,15l-1.25,2.75L15,19l2.75,1.25L19,23l1.25,-2.75L23,19l-2.75,-1.25L19,15z"/></svg>
|
||||
|
After Width: | Height: | Size: 327 B |
1
design/assets/Icons/ic-download.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#191c1c" d="M5,20h14v-2H5v2zM19,9h-4V3H9v6H5l7,7 7,-7z"/></svg>
|
||||
|
After Width: | Height: | Size: 159 B |
1
design/assets/Icons/ic-language.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#191c1c" d="M11.99,2C6.47,2 2,6.48 2,12s4.47,10 9.99,10C17.52,22 22,17.52 22,12S17.52,2 11.99,2zM18.92,8h-2.95c-0.32,-1.25 -0.78,-2.45 -1.38,-3.56 1.84,0.63 3.37,1.91 4.33,3.56zM12,4.04c0.83,1.2 1.48,2.53 1.91,3.96h-3.82c0.43,-1.43 1.08,-2.76 1.91,-3.96zM4.26,14C4.1,13.36 4,12.69 4,12s0.1,-1.36 0.26,-2h3.38c-0.08,0.66 -0.14,1.32 -0.14,2 0,0.68 0.06,1.34 0.14,2L4.26,14zM5.08,16h2.95c0.32,1.25 0.78,2.45 1.38,3.56 -1.84,-0.63 -3.37,-1.9 -4.33,-3.56zM8.03,8L5.08,8c0.96,-1.66 2.49,-2.93 4.33,-3.56C8.81,5.55 8.35,6.75 8.03,8zM12,19.96c-0.83,-1.2 -1.48,-2.53 -1.91,-3.96h3.82c-0.43,1.43 -1.08,2.76 -1.91,3.96zM14.34,14L9.66,14c-0.09,-0.66 -0.16,-1.32 -0.16,-2 0,-0.68 0.07,-1.35 0.16,-2h4.68c0.09,0.65 0.16,1.32 0.16,2 0,0.68 -0.07,1.34 -0.16,2zM14.59,19.56c0.6,-1.11 1.06,-2.31 1.38,-3.56h2.95c-0.96,1.65 -2.49,2.93 -4.33,3.56zM16.36,14c0.08,-0.66 0.14,-1.32 0.14,-2 0,-0.68 -0.06,-1.34 -0.14,-2h3.38c0.16,0.64 0.26,1.31 0.26,2s-0.1,1.36 -0.26,2h-3.38z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
1
design/assets/Icons/ic-lookup.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#191c1c" d="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3S3,5.91 3,9.5 5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zm-6,0C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14z"/></svg>
|
||||
|
After Width: | Height: | Size: 363 B |
1
design/assets/Icons/ic-pause.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#191c1c" d="M6,19h4V5H6v14zM14,5v14h4V5h-4z"/></svg>
|
||||
|
After Width: | Height: | Size: 148 B |
1
design/assets/Icons/ic-pin.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#191c1c" d="M16,9V4l1,0c0.55,0 1,-0.45 1,-1v0c0,-0.55 -0.45,-1 -1,-1H7C6.45,2 6,2.45 6,3v0c0,0.55 0.45,1 1,1l1,0v5c0,1.66 -1.34,3 -3,3h0v2h5.97v7l1,1l1,-1v-7H19v-2h0C17.34,12 16,10.66 16,9z"/></svg>
|
||||
|
After Width: | Height: | Size: 294 B |
1
design/assets/Icons/ic-share.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#191c1c" d="M18,16.08c-0.76,0 -1.44,0.3 -1.96,0.77L8.91,12.7c0.05,-0.23 0.09,-0.46 0.09,-0.7s-0.04,-0.47 -0.09,-0.7l7.05,-4.11c0.54,0.5 1.25,0.81 2.04,0.81 1.66,0 3,-1.34 3,-3s-1.34,-3 -3,-3 -3,1.34 -3,3c0,0.24 0.04,0.47 0.09,0.7L8.04,9.81C7.5,9.31 6.79,9 6,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3c0.79,0 1.5,-0.31 2.04,-0.81l7.12,4.16c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.61 1.31,2.92 2.92,2.92s2.92,-1.31 2.92,-2.92 -1.31,-2.92 -2.92,-2.92z"/></svg>
|
||||
|
After Width: | Height: | Size: 541 B |
1
design/assets/Icons/ic-view-grid.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#191c1c" d="M3,3v8h8V3H3zM9,9H5V5h4V9zM3,13v8h8v-8H3zM9,19H5v-4h4V19zM13,3v8h8V3H13zM19,9h-4V5h4V9zM13,13v8h8v-8H13zM19,19h-4v-4h4V19z"/></svg>
|
||||
|
After Width: | Height: | Size: 239 B |
1
design/assets/Icons/ic-view-list.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#191c1c" d="M3,14h4v-4H3V14zM3,19h4v-4H3V19zM3,9h4V5H3V9zM8,14h13v-4H8V14zM8,19h13v-4H8V19zM8,5v4h13V5H8z"/></svg>
|
||||
|
After Width: | Height: | Size: 210 B |
9
design/assets/Logos/README.md
Normal file
@ -0,0 +1,9 @@
|
||||
# Logos
|
||||
|
||||
The mark is an eight-pointed star (khatam) with an eight-pointed star cut from its centre, from Persian tilework. The app has no wordmark: the name گنجور is set in type (Noto Naskh Arabic) wherever it appears.
|
||||
|
||||
- `ganjoor-mark.svg`: the adaptive launcher icon as composed. The star is in paper `#f1e5ce` (sepia `surface`) on a turquoise `#00696e` (light `primary`) square. Use it on any ground.
|
||||
- `ganjoor-star-mono.svg`: the monochrome launcher layer, black on transparent, used for Android 13 themed icons. It only works on light grounds.
|
||||
- `ganjoor-launcher-round.png`: the rendered round launcher icon, xxxhdpi.
|
||||
|
||||
Don't recolour the star, add a wordmark or outline it.
|
||||
1
design/assets/Logos/ganjoor-mark.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 108 108" width="108" height="108"><path fill="#00696e" d="M0,0h108v108h-108z"/><path fill="#f1e5ce" fill-rule="evenodd" d="M54.00,20.00 L63.96,29.96 L78.04,29.96 L78.04,44.04 L88.00,54.00 L78.04,63.96 L78.04,78.04 L63.96,78.04 L54.00,88.00 L44.04,78.04 L29.96,78.04 L29.96,63.96 L20.00,54.00 L29.96,44.04 L29.96,29.96 L44.04,29.96 Z M54.00,39.00 L58.39,43.39 L64.61,43.39 L64.61,49.61 L69.00,54.00 L64.61,58.39 L64.61,64.61 L58.39,64.61 L54.00,69.00 L49.61,64.61 L43.39,64.61 L43.39,58.39 L39.00,54.00 L43.39,49.61 L43.39,43.39 L49.61,43.39 Z"/></svg>
|
||||
|
After Width: | Height: | Size: 605 B |
1
design/assets/Logos/ganjoor-star-mono.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="18 18 72 72" width="72" height="72"><path fill="#000000" fill-rule="evenodd" d="M54.00,20.00 L63.96,29.96 L78.04,29.96 L78.04,44.04 L88.00,54.00 L78.04,63.96 L78.04,78.04 L63.96,78.04 L54.00,88.00 L44.04,78.04 L29.96,78.04 L29.96,63.96 L20.00,54.00 L29.96,44.04 L29.96,29.96 L44.04,29.96 Z M54.00,39.00 L58.39,43.39 L64.61,43.39 L64.61,49.61 L69.00,54.00 L64.61,58.39 L64.61,64.61 L58.39,64.61 L54.00,69.00 L49.61,64.61 L43.39,64.61 L43.39,58.39 L39.00,54.00 L43.39,49.61 L43.39,43.39 L49.61,43.39 Z"/></svg>
|
||||
|
After Width: | Height: | Size: 558 B |
8
design/components/BottomSearchBar/README.md
Normal file
@ -0,0 +1,8 @@
|
||||
# BottomSearchBar
|
||||
|
||||
The poets search, docked at the *bottom* of the home screen where a one-handed thumb reaches.
|
||||
|
||||
- **Consumer provides:** the query and handlers for change and for "search poems".
|
||||
- A Surface at tonal elevation 3 (`surface-tonal-3`), `space-12` / `space-8` padding, holding an OutlinedTextField (`radius-xs`, `outline` border, leading search icon) labelled "Search poets".
|
||||
- Typing filters the poets in place. Once there is a query, a row appears above the field — "Search poems for “…”" with a `primary` search icon — that carries the same words into full-text poem search.
|
||||
- Rides above the keyboard (ime padding) and the navigation bar.
|
||||
6
design/components/BottomSearchBar/preview.html
Normal file
@ -0,0 +1,6 @@
|
||||
<!-- @dsCard group="Navigation" height=170 -->
|
||||
<!doctype html><html><head><meta charset="utf-8"><style>body{margin:0;background:var(--surface)} .pad{padding:16px} .stack{display:flex;flex-direction:column;gap:16px} .cap{font:12px/16px var(--font-libron);color:var(--on-surface-variant);direction:ltr;text-align:left;margin-bottom:4px}</style></head><body>
|
||||
<div class="gj" dir="rtl" lang="fa">
|
||||
<div class="gj-searchbar"><div class="gj-search-poems"><svg class="gj-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3S3,5.91 3,9.5 5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zm-6,0C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14z"/></svg>جستوجوی شعرها برای «حافظ»</div><div class="gj-field"><svg class="gj-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3S3,5.91 3,9.5 5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zm-6,0C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14z"/></svg><span style="color:var(--on-surface)">حافظ</span></div></div>
|
||||
</div>
|
||||
</body></html>
|
||||
9
design/components/Breadcrumbs/README.md
Normal file
@ -0,0 +1,9 @@
|
||||
# Breadcrumbs
|
||||
|
||||
The poem's path at the top of the reader — poet » book » section » this poem — with every ancestor tappable.
|
||||
|
||||
- **Consumer provides:** the poem's full title and URL; crumbs are split from them.
|
||||
- `title-medium`. Ancestors are `primary` and open that category; the current poem is `on-surface` and not a link; the ` » ` separators are `on-surface-variant`.
|
||||
- Wraps onto further lines (FlowRow) instead of truncating — a long Golestan path stays readable.
|
||||
- If no crumbs can be derived, show the full title as plain `title-medium` text.
|
||||
- Below it: the recitation player, then the metre line in `body-small` / `on-surface-variant`.
|
||||
7
design/components/Breadcrumbs/preview.html
Normal file
@ -0,0 +1,7 @@
|
||||
<!-- @dsCard group="Reading" height=120 -->
|
||||
<!doctype html><html><head><meta charset="utf-8"><style>body{margin:0;background:var(--surface)} .pad{padding:16px} .stack{display:flex;flex-direction:column;gap:16px} .cap{font:12px/16px var(--font-libron);color:var(--on-surface-variant);direction:ltr;text-align:left;margin-bottom:4px}</style></head><body>
|
||||
<div class="gj pad" dir="rtl" lang="fa">
|
||||
<nav class="gj-crumbs" aria-label="مسیر"><a>حافظ</a><span class="gj-sep"> » </span><a>غزلیات</a><span class="gj-sep"> » </span><span class="gj-here" aria-current="page">غزل شمارهٔ ۳</span></nav>
|
||||
<div class="gj-metre">مفاعیلن مفاعیلن مفاعیلن مفاعیلن (هزج مثمن سالم)</div>
|
||||
</div>
|
||||
</body></html>
|
||||
70
design/components/ColumnBrowser/README.md
Normal file
@ -0,0 +1,70 @@
|
||||
# ColumnBrowser
|
||||
|
||||
The layout for tablets and unfolded foldables. A narrow column of poets sits on the right. Beside it is a column for each level of the open book (books › chapters › poems), and the open page fills the rest of the screen on the left. Phones keep today's navigation. Built in `app/src/main/java/com/ganjoor/android/ui/ColumnBrowser.kt`; wireframes are in `design/wireframes/` (1–9).
|
||||
|
||||
## Window sizes
|
||||
|
||||
| Width | Layout |
|
||||
|---|---|
|
||||
| < 600dp (phones, a folded foldable) | Unchanged: one screen at a time. |
|
||||
| 600–839dp (small tablets, a book-style foldable open in portrait) | Poets column, the **newest** list column only (its header has a back arrow to the level above), and the page. |
|
||||
| ≥ 840dp (tablets, an open foldable in landscape) | Poets column, up to three list columns, and the page. |
|
||||
|
||||
Both layouts use the same navigation routes, so folding or unfolding keeps your place.
|
||||
|
||||
## Columns, right to left
|
||||
|
||||
1. **Poets** (`surface-container`, 96dp; 80dp once two or more lists are open or a poem is being read): a portrait disc (56dp, or 44dp when narrow) with the name underneath in `label-medium`. The selected poet gets a `secondary-container` tile and a 2dp `primary` ring around the portrait. The poets appear in the same order as on the home screen, pinned poets first.
|
||||
2. **List columns** (`surface-container-low`): a poet's books, a book's chapters, a chapter's poems. The header shows the title of what the column lists. Selecting a row replaces every column to its left. Each column keeps its own scroll position.
|
||||
3. **The page** (`surface`): the usual TopAppBar, then the breadcrumbs, then the content at a centred measure (680dp for a poem, 760dp for a book's cards).
|
||||
|
||||
## Columns give their room to the page
|
||||
|
||||
| Column | Browsing | Reading a poem |
|
||||
|---|---|---|
|
||||
| Newest | 224dp: `body-large` titles, poems show their first line, and a chevron where a row opens another column | 168dp, terse |
|
||||
| One back | 168dp, terse | 132dp, terse |
|
||||
| Two back | 132dp, terse | 132dp, terse |
|
||||
|
||||
**Terse** means `body-medium` titles wrapped to two lines and cut with an ellipsis, with no first lines and no chevrons. Width changes animate over 300ms. A new column opens out from zero width.
|
||||
|
||||
## Reader view
|
||||
|
||||
- The **hide** button (`menu_open`) is the first action in the page's top bar. It slides the columns away to the right, and the page takes the whole width, still centred.
|
||||
- While the columns are hidden, a **floating button** (`menu`) at the bottom-right corner brings them back exactly as they were.
|
||||
- The choice is saved (`columnsHidden` in Settings).
|
||||
|
||||
## Couplets
|
||||
|
||||
When the page is at least 640dp wide, a Right+Left couplet sits on one line: the first half at the start, the second at the end, 32dp apart. When it's narrower, couplets stack as on the phone. Centred verses and prose always keep their own lines.
|
||||
|
||||
## Dictionary
|
||||
|
||||
On large screens, tapping a word opens **WordPanel** on the left instead of the bottom sheet:
|
||||
- `surface-container-low`, 360dp wide, with a "Dictionary" header, a ✕ button, and the same lookup and couplet actions as the sheet.
|
||||
- The poem moves over to make room, so nothing being read is covered.
|
||||
- The tapped word stays highlighted in the verse (`secondary-container` / `on-secondary-container`), on phones too.
|
||||
- ✕ or Back closes the panel.
|
||||
- The panel only opens where the page keeps at least **400dp** beside it. On a book-style
|
||||
foldable held open in portrait the columns have already taken their room, and a panel
|
||||
there left the verse a couple of characters a line. Below that the sheet is used instead:
|
||||
it covers the foot of the poem but leaves the lines whole.
|
||||
|
||||
## Reading settings
|
||||
|
||||
On large screens the gear opens **ReadingSettingsPanel** on the left, where the dictionary opens, instead of the bottom sheet:
|
||||
- It has the same width and surface as the dictionary panel, with a "Reading settings" header and a ✕ button. The settings and their order are the same as in the sheet.
|
||||
- The page stays in view beside the panel, so a change of theme, font, weight or size shows on the poem as it's made.
|
||||
- While the panel is open, the poets and list columns fold away so the page keeps its room. When it closes they come back, but only if they were showing before: this never changes the saved reader-view choice, and the floating "show the list" button stays hidden while the panel is open.
|
||||
- ✕ or Back closes it. Phones keep the bottom sheet.
|
||||
|
||||
## Loading
|
||||
|
||||
Nothing covers the whole screen while it loads, on phones as well:
|
||||
- Only the part that is waiting shows a skeleton shaped like its content (`Skeleton.kt`): list rows in a column, portrait discs in the poets column, cards on a book's page, couplets on a poem.
|
||||
- The real top bar stays visible above the skeleton, so Back already works.
|
||||
- The skeletons pulse softly, and the content fades in over them when it arrives.
|
||||
|
||||
## Not yet done
|
||||
|
||||
- Lining the page edge up with a foldable's hinge (`FoldingFeature`, Jetpack WindowManager).
|
||||
13
design/components/Couplet/README.md
Normal file
@ -0,0 +1,13 @@
|
||||
# Couplet
|
||||
|
||||
One line of poetry: two hemistichs stacked on a phone, the first pushed to the start of the line and the second to the end — the way Ganjoor itself reads.
|
||||
|
||||
- **Consumer provides:** the couplet's verses (each with its position), the reading style from settings (`poem-naskh` or `poem-nastaliq` at the reader's size and weight), and handlers for a tapped word and for the actions.
|
||||
- Alignment by verse position: first hemistich `start`, second `end`, centred verses `center`, prose (single/paragraph/comment) `justify` so Golestan and Nowruznameh fill the column.
|
||||
- Every line of verse sits in its own **card**: `surface-container-high` (`surface-container-highest` on OLED black, where the usual step is all but black), `radius-md` (12dp) corners, 12dp/8dp inner padding and 4dp above and below. The card holds the couplet's options chevron, its actions and its summary, so they visibly belong to it. Text is `on-surface`, at least 6:1 on the card in every theme.
|
||||
- Prose (Single, Paragraph or Comment positions: Golestan, Nowruznameh) has no card; it keeps `space-6` above and below. A paragraph in a box would read as a quotation.
|
||||
- The cards are the same on phones, tablets and foldables (wireframes 4–9).
|
||||
- Tapping a word opens the dictionary sheet; tapping between words, or the 32px chevron (`touch-compact`, 20px icon, `on-surface-variant`), opens the couplet's actions: **Save this passage**, **Copy**, **Share** as TextButtons. Save keeps the link back to the poem; Copy doesn't.
|
||||
- Long-press belongs to text selection — never bind actions to it here.
|
||||
- With summaries on, Ganjoor's couplet summary sits underneath in `body-small` / `on-surface-variant`.
|
||||
- Nastaliq needs `2.4` leading or its swashes clip the line above; naskh uses `1.8`.
|
||||
10
design/components/Couplet/preview.html
Normal file
@ -0,0 +1,10 @@
|
||||
<!-- @dsCard group="Reading" height=330 -->
|
||||
<!doctype html><html><head><meta charset="utf-8"><style>body{margin:0;background:var(--surface)} .pad{padding:16px} .stack{display:flex;flex-direction:column;gap:16px} .cap{font:12px/16px var(--font-libron);color:var(--on-surface-variant);direction:ltr;text-align:left;margin-bottom:4px}</style></head><body>
|
||||
<div class="gj pad" dir="rtl" lang="fa">
|
||||
<div class="gj-couplet"><p class="gj-verse is-first">الا یا ایها الساقی ادر کأسا و ناولها</p><p class="gj-verse is-second">که عشق آسان نمود اول ولی افتاد مشکلها</p>
|
||||
<div class="gj-couplet-toggle"><button class="gj-icon-btn" aria-label="گزینههای این بیت" aria-expanded="true"><svg class="gj-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z"/></svg></button></div>
|
||||
<div class="gj-actions"><button class="gj-text-btn">ذخیرهٔ این بخش</button><button class="gj-text-btn">رونوشت</button><button class="gj-text-btn"><svg class="gj-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M18,16.08c-0.76,0 -1.44,0.3 -1.96,0.77L8.91,12.7c0.05,-0.23 0.09,-0.46 0.09,-0.7s-0.04,-0.47 -0.09,-0.7l7.05,-4.11c0.54,0.5 1.25,0.81 2.04,0.81 1.66,0 3,-1.34 3,-3s-1.34,-3 -3,-3 -3,1.34 -3,3c0,0.24 0.04,0.47 0.09,0.7L8.04,9.81C7.5,9.31 6.79,9 6,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3c0.79,0 1.5,-0.31 2.04,-0.81l7.12,4.16c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.61 1.31,2.92 2.92,2.92s2.92,-1.31 2.92,-2.92 -1.31,-2.92 -2.92,-2.92z"/></svg>همرسانی</button></div></div>
|
||||
<div class="gj-couplet is-nastaliq"><p class="gj-verse is-first">به بوی نافهای کاخر صبا زان طره بگشاید</p><p class="gj-verse is-second">ز تاب جعد مشکینش چه خون افتاد در دلها</p>
|
||||
<div class="gj-couplet-toggle"><button class="gj-icon-btn" aria-label="گزینههای این بیت" aria-expanded="false"><svg class="gj-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/></svg></button></div></div>
|
||||
</div>
|
||||
</body></html>
|
||||
31
design/components/Cover/preview.html
Normal file
@ -0,0 +1,31 @@
|
||||
<!-- @dsCard height=288 -->
|
||||
<!doctype html><html><head><meta charset="utf-8"><style>
|
||||
html,body{margin:0;background:var(--surface)}
|
||||
.c{position:relative;width:960px;height:288px;overflow:hidden;background:var(--surface)}
|
||||
svg{position:absolute;inset:0}
|
||||
.p{fill:var(--primary)}.pc{fill:var(--primary-container)}.s{fill:var(--secondary)}.sc{fill:var(--secondary-container)}.ink{fill:var(--on-background)}.cut{fill:var(--surface)}
|
||||
.b{rx:var(--radius-md)}
|
||||
.t{position:absolute;left:var(--space-32);bottom:var(--space-24);max-width:440px}
|
||||
.n{font:400 110px/.95 var(--font-libron);color:var(--on-surface);margin:0;letter-spacing:-1px}
|
||||
.g{font:400 14px/20px var(--font-libron);color:var(--on-surface-variant);margin:var(--space-12) 0 0}
|
||||
</style></head><body><div class="c">
|
||||
<svg width="960" height="288" viewBox="0 0 960 288" aria-hidden="true">
|
||||
<!-- blocks: primary 240×288 slab (bleeds top and bottom), secondary 160×120, secondary-container 160×96, on-background 160×72, primary-container 80×120; corners radius-md
|
||||
arrangement: one tall turquoise slab with satellites stacked flush on its far side, like a tile panel
|
||||
pattern: literal motif, the khatam eight-point star from the launcher mark and the "Persian tilework" note in Theme.kt, cut in surface out of the slab, 12 stars at an 80px pitch (space-20×4)
|
||||
scales: pitch 80 = 4×space-20, gaps space-8, star 48 = 2×space-24 -->
|
||||
<defs><path id="k" d="M0,-24 L7.03,-16.97 L16.97,-16.97 L16.97,-7.03 L24,0 L16.97,7.03 L16.97,16.97 L7.03,16.97 L0,24 L-7.03,16.97 L-16.97,16.97 L-16.97,7.03 L-24,0 L-16.97,-7.03 L-16.97,-16.97 L-7.03,-16.97 Z"/></defs>
|
||||
<rect class="pc b" x="480" y="160" width="72" height="128"/>
|
||||
<rect class="p b" x="560" y="-12" width="240" height="312"/>
|
||||
<g class="cut">
|
||||
<use href="#k" x="600" y="40"/><use href="#k" x="680" y="40"/><use href="#k" x="760" y="40"/>
|
||||
<use href="#k" x="600" y="120"/><use href="#k" x="680" y="120"/><use href="#k" x="760" y="120"/>
|
||||
<use href="#k" x="600" y="200"/><use href="#k" x="680" y="200"/><use href="#k" x="760" y="200"/>
|
||||
<use href="#k" x="600" y="280"/><use href="#k" x="680" y="280"/><use href="#k" x="760" y="280"/>
|
||||
</g>
|
||||
<rect class="s b" x="808" y="-12" width="164" height="124"/>
|
||||
<rect class="sc b" x="808" y="120" width="164" height="88"/>
|
||||
<rect class="ink b" x="808" y="216" width="164" height="84"/>
|
||||
</svg>
|
||||
<div class="t"><p class="n">Ganjoor</p><p class="g">Persian poetry, set for long reading: tilework turquoise, saffron and paper.</p></div>
|
||||
</div></body></html>
|
||||
9
design/components/DownloadStatus/README.md
Normal file
@ -0,0 +1,9 @@
|
||||
# DownloadStatus
|
||||
|
||||
Fetch a poet's poems, or say they are already here — three states, no menu.
|
||||
|
||||
- **Consumer provides:** the poet's slug; state comes from the download queue and the offline store.
|
||||
- **Idle:** the download arrow in `on-surface-variant`, tap to start.
|
||||
- **Downloading:** an 18px (`icon-inline`) progress ring, `space-2` stroke, in `primary`; tap cancels.
|
||||
- **Saved:** the CheckCircle in `downloaded` green — a tick shape as well as a colour. It sits in a 48px (`touch`) box even though it is not tappable, so ticks and arrows line up down the column.
|
||||
- Deleting is never offered here; it lives on the Downloads screen next to the sizes. A tap beside a poet's name should never throw their poems away.
|
||||
8
design/components/DownloadStatus/preview.html
Normal file
@ -0,0 +1,8 @@
|
||||
<!-- @dsCard group="Status" height=110 -->
|
||||
<!doctype html><html><head><meta charset="utf-8"><style>body{margin:0;background:var(--surface)} .pad{padding:16px} .stack{display:flex;flex-direction:column;gap:16px} .cap{font:12px/16px var(--font-libron);color:var(--on-surface-variant);direction:ltr;text-align:left;margin-bottom:4px}</style></head><body>
|
||||
<div class="gj pad" dir="rtl" lang="fa" style="display:flex;gap:24px;align-items:center">
|
||||
<div style="text-align:center"><button class="gj-icon-btn" aria-label="دریافت"><svg class="gj-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M5,20h14v-2H5v2zM19,9h-4V3H9v6H5l7,7 7,-7z"/></svg></button><div class="cap" style="text-align:center">Idle</div></div>
|
||||
<div style="text-align:center"><button class="gj-icon-btn" aria-label="لغو دریافت"><span class="gj-ring"></span></button><div class="cap" style="text-align:center">Downloading</div></div>
|
||||
<div style="text-align:center"><span class="gj-dl is-saved" role="img" aria-label="ذخیره شده"><svg class="gj-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg></span><div class="cap" style="text-align:center">Saved</div></div>
|
||||
</div>
|
||||
</body></html>
|
||||
9
design/components/FilterChips/README.md
Normal file
@ -0,0 +1,9 @@
|
||||
# FilterChips
|
||||
|
||||
A wrapping row of single-choice Material FilterChips — poet sort order on the home screen, and theme, font, weight and language in the reading settings sheet.
|
||||
|
||||
- **Consumer provides:** the options, the selected one, and a label for each.
|
||||
- 32px tall, `radius-sm`, `label-large`, `space-8` apart, wrapping (FlowRow).
|
||||
- Selected: `secondary-container` fill, `on-secondary-container` label and a check mark. Unselected: transparent with a 1px border.
|
||||
- Exactly one is always selected; these are a choice, not filters that can all be off.
|
||||
- Language chips label each language in its own script (فارسی · اردو · English), whatever the UI language.
|
||||
7
design/components/FilterChips/preview.html
Normal file
@ -0,0 +1,7 @@
|
||||
<!-- @dsCard group="Selection" height=150 -->
|
||||
<!doctype html><html><head><meta charset="utf-8"><style>body{margin:0;background:var(--surface)} .pad{padding:16px} .stack{display:flex;flex-direction:column;gap:16px} .cap{font:12px/16px var(--font-libron);color:var(--on-surface-variant);direction:ltr;text-align:left;margin-bottom:4px}</style></head><body>
|
||||
<div class="gj pad stack" dir="rtl" lang="fa">
|
||||
<div class="gj-chips" role="group"><button class="gj-chip" aria-pressed="true"><svg class="gj-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>برگزیده</button><button class="gj-chip" aria-pressed="false">ترتیب گنجور</button><button class="gj-chip" aria-pressed="false">الفبایی</button></div>
|
||||
<div class="gj-chips" role="group"><button class="gj-chip" aria-pressed="false">سیستم</button><button class="gj-chip" aria-pressed="false">روشن</button><button class="gj-chip" aria-pressed="false">تاریک</button><button class="gj-chip" aria-pressed="true"><svg class="gj-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>کاغذی</button><button class="gj-chip" aria-pressed="false">کاغذی شب</button></div>
|
||||
</div>
|
||||
</body></html>
|
||||
8
design/components/PassageActions/README.md
Normal file
@ -0,0 +1,8 @@
|
||||
# PassageActions
|
||||
|
||||
The row of TextButtons under an opened couplet: save the passage, copy it, share it.
|
||||
|
||||
- **Consumer provides:** the passage (excerpt plus the poem's URL and title).
|
||||
- TextButtons in `label-large`, `space-8` apart. **Save** reads in the content colour until saved, then turns `primary` and says **Saved** — the state is in the word, not only the colour.
|
||||
- **Share** carries the standard Android share glyph at `icon-inline` (18px): people recognise the shape before the word.
|
||||
- The same row appears in the word sheet, so a passage can be saved from wherever the reader is.
|
||||
7
design/components/PassageActions/preview.html
Normal file
@ -0,0 +1,7 @@
|
||||
<!-- @dsCard group="Actions" height=150 -->
|
||||
<!doctype html><html><head><meta charset="utf-8"><style>body{margin:0;background:var(--surface)} .pad{padding:16px} .stack{display:flex;flex-direction:column;gap:16px} .cap{font:12px/16px var(--font-libron);color:var(--on-surface-variant);direction:ltr;text-align:left;margin-bottom:4px}</style></head><body>
|
||||
<div class="gj pad stack" dir="rtl" lang="fa">
|
||||
<div><div class="cap">Not saved</div><div class="gj-actions"><button class="gj-text-btn is-neutral">ذخیرهٔ این بخش</button><button class="gj-text-btn">رونوشت</button><button class="gj-text-btn"><svg class="gj-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M18,16.08c-0.76,0 -1.44,0.3 -1.96,0.77L8.91,12.7c0.05,-0.23 0.09,-0.46 0.09,-0.7s-0.04,-0.47 -0.09,-0.7l7.05,-4.11c0.54,0.5 1.25,0.81 2.04,0.81 1.66,0 3,-1.34 3,-3s-1.34,-3 -3,-3 -3,1.34 -3,3c0,0.24 0.04,0.47 0.09,0.7L8.04,9.81C7.5,9.31 6.79,9 6,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3c0.79,0 1.5,-0.31 2.04,-0.81l7.12,4.16c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.61 1.31,2.92 2.92,2.92s2.92,-1.31 2.92,-2.92 -1.31,-2.92 -2.92,-2.92z"/></svg>همرسانی</button></div></div>
|
||||
<div><div class="cap">Saved</div><div class="gj-actions"><button class="gj-text-btn">ذخیره شد</button><button class="gj-text-btn">رونوشت</button><button class="gj-text-btn"><svg class="gj-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M18,16.08c-0.76,0 -1.44,0.3 -1.96,0.77L8.91,12.7c0.05,-0.23 0.09,-0.46 0.09,-0.7s-0.04,-0.47 -0.09,-0.7l7.05,-4.11c0.54,0.5 1.25,0.81 2.04,0.81 1.66,0 3,-1.34 3,-3s-1.34,-3 -3,-3 -3,1.34 -3,3c0,0.24 0.04,0.47 0.09,0.7L8.04,9.81C7.5,9.31 6.79,9 6,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3c0.79,0 1.5,-0.31 2.04,-0.81l7.12,4.16c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.61 1.31,2.92 2.92,2.92s2.92,-1.31 2.92,-2.92 -1.31,-2.92 -2.92,-2.92z"/></svg>همرسانی</button></div></div>
|
||||
</div>
|
||||
</body></html>
|
||||
9
design/components/PoetCard/README.md
Normal file
@ -0,0 +1,9 @@
|
||||
# PoetCard
|
||||
|
||||
One poet in the home grid: a round portrait over their name, in a filled card.
|
||||
|
||||
- **Consumer provides:** the poet (name, portrait URL), whether they are pinned, and click / long-click handlers.
|
||||
- Filled Card: `surface-container-highest`, `radius-md`, `space-12` padding, `space-8` gap. The grid is adaptive with columns at least `poet-grid-min` (132px), `space-12` gutters.
|
||||
- The portrait is an `avatar-card` (84px) disc on `secondary-container`; the poet's initial (`headline-medium`, `on-secondary-container`) sits *underneath* the photo so a missing portrait is never a hole.
|
||||
- Name in `title-medium`, centred, up to two lines.
|
||||
- Long-press pins the poet; a pinned poet shows the 16px pin (`icon-pin`, `primary`) before the name. The "Pinned" sort puts them first.
|
||||
8
design/components/PoetCard/preview.html
Normal file
@ -0,0 +1,8 @@
|
||||
<!-- @dsCard group="Poets" height=300 -->
|
||||
<!doctype html><html><head><meta charset="utf-8"><style>body{margin:0;background:var(--surface)} .pad{padding:16px} .stack{display:flex;flex-direction:column;gap:16px} .cap{font:12px/16px var(--font-libron);color:var(--on-surface-variant);direction:ltr;text-align:left;margin-bottom:4px}</style></head><body>
|
||||
<div class="gj pad" dir="rtl" lang="fa"><div class="gj-poet-grid">
|
||||
<div class="gj-card" tabindex="0"><div class="gj-portrait is-card">ح</div><div class="gj-name"><svg class="gj-pin" viewBox="0 0 24 24" aria-label="برداشتن سنجاق"><path d="M16,9V4l1,0c0.55,0 1,-0.45 1,-1v0c0,-0.55 -0.45,-1 -1,-1H7C6.45,2 6,2.45 6,3v0c0,0.55 0.45,1 1,1l1,0v5c0,1.66 -1.34,3 -3,3h0v2h5.97v7l1,1l1,-1v-7H19v-2h0C17.34,12 16,10.66 16,9z"/></svg>حافظ</div></div>
|
||||
<div class="gj-card" tabindex="0"><div class="gj-portrait is-card">خ</div><div class="gj-name">خیام</div></div>
|
||||
<div class="gj-card" tabindex="0"><div class="gj-portrait is-card">پ</div><div class="gj-name">پروین اعتصامی</div></div>
|
||||
</div></div>
|
||||
</body></html>
|
||||
8
design/components/PoetRow/README.md
Normal file
@ -0,0 +1,8 @@
|
||||
# PoetRow
|
||||
|
||||
One poet as a list row, for readers who would rather scan names than faces.
|
||||
|
||||
- **Consumer provides:** the poet, whether they are pinned, and click / long-click handlers.
|
||||
- Material ListItem: `avatar-row` (44px) portrait disc leading, name in `title-medium`, trailing pin mark plus the **DownloadStatus** for that poet.
|
||||
- Same pin behaviour as PoetCard: long-press to pin; the pin mark shows only on pinned poets.
|
||||
- The grid/list choice is a top-bar toggle and is remembered.
|
||||
8
design/components/PoetRow/preview.html
Normal file
@ -0,0 +1,8 @@
|
||||
<!-- @dsCard group="Poets" height=230 -->
|
||||
<!doctype html><html><head><meta charset="utf-8"><style>body{margin:0;background:var(--surface)} .pad{padding:16px} .stack{display:flex;flex-direction:column;gap:16px} .cap{font:12px/16px var(--font-libron);color:var(--on-surface-variant);direction:ltr;text-align:left;margin-bottom:4px}</style></head><body>
|
||||
<div class="gj" dir="rtl" lang="fa">
|
||||
<div class="gj-row" tabindex="0"><div class="gj-portrait is-row">ح</div><div class="gj-row-text"><div class="gj-name" style="text-align:start">حافظ</div></div><div class="gj-row-trailing"><svg class="gj-pin" viewBox="0 0 24 24" aria-label="برداشتن سنجاق"><path d="M16,9V4l1,0c0.55,0 1,-0.45 1,-1v0c0,-0.55 -0.45,-1 -1,-1H7C6.45,2 6,2.45 6,3v0c0,0.55 0.45,1 1,1l1,0v5c0,1.66 -1.34,3 -3,3h0v2h5.97v7l1,1l1,-1v-7H19v-2h0C17.34,12 16,10.66 16,9z"/></svg><span class="gj-dl is-saved" role="img" aria-label="ذخیره شده"><svg class="gj-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg></span></div></div>
|
||||
<div class="gj-row" tabindex="0"><div class="gj-portrait is-row">س</div><div class="gj-row-text"><div class="gj-name">سعدی</div></div><div class="gj-row-trailing"><button class="gj-icon-btn" aria-label="لغو دریافت"><span class="gj-ring"></span></button></div></div>
|
||||
<div class="gj-row" tabindex="0"><div class="gj-portrait is-row">م</div><div class="gj-row-text"><div class="gj-name">مولانا</div></div><div class="gj-row-trailing"><button class="gj-icon-btn" aria-label="دریافت"><svg class="gj-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M5,20h14v-2H5v2zM19,9h-4V3H9v6H5l7,7 7,-7z"/></svg></button></div></div>
|
||||
</div>
|
||||
</body></html>
|
||||
10
design/components/ReadingSettingsSheet/README.md
Normal file
@ -0,0 +1,10 @@
|
||||
# ReadingSettingsSheet
|
||||
|
||||
The Material bottom sheet behind the gear icon: theme, OLED, font, weight, text size with a live preview, language, offline mode and summaries.
|
||||
|
||||
- **Consumer provides:** nothing; it reads and writes the app's settings.
|
||||
- `surface-container-low` with `radius-xl` top corners; `space-20` side padding, `space-8` between rows, `space-32` at the bottom. It opens half-height and scrolls, inset above the navigation bar.
|
||||
- Order: Theme chips → OLED toggle → Font chips → Weight chips → Text size slider (14–40, steps of 2) → **live preview** of a hemistich in the chosen reading style → Language chips → Offline mode → Show summaries → AI assistant, About & licences as TextButtons.
|
||||
- Every group has a **SectionLabel**. Changes apply immediately; there is no Save.
|
||||
- Changing language recreates the activity.
|
||||
- On tablets and unfolded foldables the same settings open as a panel on the left instead (`ReadingSettingsPanel`, see ColumnBrowser), so the poem stays in view while they change.
|
||||
11
design/components/ReadingSettingsSheet/preview.html
Normal file
@ -0,0 +1,11 @@
|
||||
<!-- @dsCard group="Overlays" height=560 -->
|
||||
<!doctype html><html><head><meta charset="utf-8"><style>body{margin:0;background:var(--surface)} .pad{padding:16px} .stack{display:flex;flex-direction:column;gap:16px} .cap{font:12px/16px var(--font-libron);color:var(--on-surface-variant);direction:ltr;text-align:left;margin-bottom:4px}</style></head><body>
|
||||
<div class="gj" dir="rtl" lang="fa" style="background:var(--surface);padding-top:24px">
|
||||
<div class="gj-sheet"><div class="gj-handle"></div>
|
||||
<div class="gj-label">پوسته</div><div class="gj-chips"><button class="gj-chip" aria-pressed="false">سیستم</button><button class="gj-chip" aria-pressed="true"><svg class="gj-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>روشن</button><button class="gj-chip" aria-pressed="false">تاریک</button><button class="gj-chip" aria-pressed="false">کاغذی</button><button class="gj-chip" aria-pressed="false">کاغذی شب</button></div>
|
||||
<div class="gj-toggle"><div class="gj-toggle-text"><div class="gj-toggle-title">پسزمینهٔ سیاه (OLED)</div><div class="gj-toggle-note">روی پوستههای تاریک اعمال میشود</div></div><button class="gj-switch" role="switch" aria-checked="false" aria-label="OLED"></button></div>
|
||||
<div class="gj-label">قلم</div><div class="gj-chips"><button class="gj-chip" aria-pressed="true"><svg class="gj-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>نسخ</button><button class="gj-chip" aria-pressed="false">نستعلیق</button></div>
|
||||
<div class="gj-label">اندازهٔ متن</div><input type="range" min="14" max="40" step="2" value="22" style="accent-color:var(--primary);width:100%">
|
||||
<p class="gj-verse" style="margin:0">الا یا ایها الساقی ادر کأسا و ناولها</p>
|
||||
</div></div>
|
||||
</body></html>
|
||||
6
design/components/SectionLabel/README.md
Normal file
@ -0,0 +1,6 @@
|
||||
# SectionLabel
|
||||
|
||||
The heading over each group in the reading settings sheet.
|
||||
|
||||
- **Consumer provides:** the text.
|
||||
- `title-small` in `primary`, with `space-12` above it. Not a link and not tappable; it only names the group of chips, slider or toggle beneath.
|
||||
4
design/components/SectionLabel/preview.html
Normal file
@ -0,0 +1,4 @@
|
||||
<!-- @dsCard group="Selection" height=90 -->
|
||||
<!doctype html><html><head><meta charset="utf-8"><style>body{margin:0;background:var(--surface)} .pad{padding:16px} .stack{display:flex;flex-direction:column;gap:16px} .cap{font:12px/16px var(--font-libron);color:var(--on-surface-variant);direction:ltr;text-align:left;margin-bottom:4px}</style></head><body>
|
||||
<div class="gj pad" dir="rtl" lang="fa"><div class="gj-label">پوسته</div><div class="gj-label">اندازهٔ متن</div></div>
|
||||
</body></html>
|
||||
8
design/components/SettingToggle/README.md
Normal file
@ -0,0 +1,8 @@
|
||||
# SettingToggle
|
||||
|
||||
A setting with a one-line explanation and a Material Switch.
|
||||
|
||||
- **Consumer provides:** a title, a note and the checked state.
|
||||
- Title in `body-large` / `on-surface`; the note under it in `body-small` / `on-surface-variant`; the Switch at the end. `space-12` above each toggle.
|
||||
- The note says what the setting does *and* when it applies ("Applies to the dark themes; saves power on OLED screens"). Put a toggle beside the choice it modifies — OLED sits right under the theme chips.
|
||||
- Switch on: `primary` track, `on-primary` thumb. Off: `surface-container-highest` track with an `outline` border and thumb.
|
||||
7
design/components/SettingToggle/preview.html
Normal file
@ -0,0 +1,7 @@
|
||||
<!-- @dsCard group="Selection" height=170 -->
|
||||
<!doctype html><html><head><meta charset="utf-8"><style>body{margin:0;background:var(--surface)} .pad{padding:16px} .stack{display:flex;flex-direction:column;gap:16px} .cap{font:12px/16px var(--font-libron);color:var(--on-surface-variant);direction:ltr;text-align:left;margin-bottom:4px}</style></head><body>
|
||||
<div class="gj pad" dir="rtl" lang="fa">
|
||||
<div class="gj-toggle"><div class="gj-toggle-text"><div class="gj-toggle-title">پسزمینهٔ سیاه (OLED)</div><div class="gj-toggle-note">روی پوستههای تاریک اعمال میشود و در نمایشگر OLED باتری کمتری میبرد</div></div><button class="gj-switch" role="switch" aria-checked="true" aria-label="پسزمینهٔ سیاه"></button></div>
|
||||
<div class="gj-toggle"><div class="gj-toggle-text"><div class="gj-toggle-title">حالت برونخط</div><div class="gj-toggle-note">فقط از شعرهای ذخیرهشده بخوان</div></div><button class="gj-switch" role="switch" aria-checked="false" aria-label="حالت برونخط"></button></div>
|
||||
</div>
|
||||
</body></html>
|
||||
10
design/components/TopAppBar/README.md
Normal file
@ -0,0 +1,10 @@
|
||||
# TopAppBar
|
||||
|
||||
The Material 3 small top app bar every screen uses: back arrow, a one-line title, then a row of icon actions.
|
||||
|
||||
- **Consumer provides:** the title (a poem title, a category, or the app name گنجور), an optional up action, and the actions for that screen.
|
||||
- Title in `title-large`, ellipsized to one line. The navigation icon is `on-surface`; action icons are `on-surface-variant`, 24px in a 48px `touch` target.
|
||||
- A toggled action (a saved bookmark heart) turns `primary`, so the state is visible without reading the label.
|
||||
- Action order on the poem screen: Home, Share, Bookmark, Reading settings. On the poets screen: view toggle, language, bookmarks, downloads, reading settings. Reading settings is always last, so it is always in the same place.
|
||||
- The arrow is auto-mirrored: in the RTL layout it points right.
|
||||
- Don't add a menu overflow; every action has its own icon and content description.
|
||||
9
design/components/TopAppBar/preview.html
Normal file
@ -0,0 +1,9 @@
|
||||
<!-- @dsCard group="Navigation" height=200 -->
|
||||
<!doctype html><html><head><meta charset="utf-8"><style>body{margin:0;background:var(--surface)} .pad{padding:16px} .stack{display:flex;flex-direction:column;gap:16px} .cap{font:12px/16px var(--font-libron);color:var(--on-surface-variant);direction:ltr;text-align:left;margin-bottom:4px}</style></head><body>
|
||||
<div class="gj" dir="rtl" lang="fa">
|
||||
<div class="gj-topbar"><button class="gj-icon-btn gj-nav" aria-label="بازگشت"><svg class="gj-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg></button><div class="gj-topbar-title">غزل شمارهٔ ۳</div>
|
||||
<div class="gj-topbar-actions"><button class="gj-icon-btn" aria-label="خانه"><svg class="gj-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg></button><button class="gj-icon-btn" aria-label="همرسانی"><svg class="gj-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M18,16.08c-0.76,0 -1.44,0.3 -1.96,0.77L8.91,12.7c0.05,-0.23 0.09,-0.46 0.09,-0.7s-0.04,-0.47 -0.09,-0.7l7.05,-4.11c0.54,0.5 1.25,0.81 2.04,0.81 1.66,0 3,-1.34 3,-3s-1.34,-3 -3,-3 -3,1.34 -3,3c0,0.24 0.04,0.47 0.09,0.7L8.04,9.81C7.5,9.31 6.79,9 6,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3c0.79,0 1.5,-0.31 2.04,-0.81l7.12,4.16c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.61 1.31,2.92 2.92,2.92s2.92,-1.31 2.92,-2.92 -1.31,-2.92 -2.92,-2.92z"/></svg></button><button class="gj-icon-btn gj-on" aria-label="حذف نشانک"><svg class="gj-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg></button><button class="gj-icon-btn" aria-label="تنظیمات خواندن"><svg class="gj-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.07-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.07,0.94l-2.03,1.58c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"/></svg></button></div></div>
|
||||
<hr class="gj-divider">
|
||||
<div class="gj-topbar"><div class="gj-topbar-title">گنجور</div><div class="gj-topbar-actions"><button class="gj-icon-btn" aria-label="نمایش فهرستی"><svg class="gj-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M3,3v8h8V3H3zM9,9H5V5h4V9zM3,13v8h8v-8H3zM9,19H5v-4h4V19zM13,3v8h8V3H13zM19,9h-4V5h4V9zM13,13v8h8v-8H13zM19,19h-4v-4h4V19z"/></svg></button><button class="gj-icon-btn" aria-label="زبان"><svg class="gj-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M11.99,2C6.47,2 2,6.48 2,12s4.47,10 9.99,10C17.52,22 22,17.52 22,12S17.52,2 11.99,2zM18.92,8h-2.95c-0.32,-1.25 -0.78,-2.45 -1.38,-3.56 1.84,0.63 3.37,1.91 4.33,3.56zM12,4.04c0.83,1.2 1.48,2.53 1.91,3.96h-3.82c0.43,-1.43 1.08,-2.76 1.91,-3.96zM4.26,14C4.1,13.36 4,12.69 4,12s0.1,-1.36 0.26,-2h3.38c-0.08,0.66 -0.14,1.32 -0.14,2 0,0.68 0.06,1.34 0.14,2L4.26,14zM5.08,16h2.95c0.32,1.25 0.78,2.45 1.38,3.56 -1.84,-0.63 -3.37,-1.9 -4.33,-3.56zM8.03,8L5.08,8c0.96,-1.66 2.49,-2.93 4.33,-3.56C8.81,5.55 8.35,6.75 8.03,8zM12,19.96c-0.83,-1.2 -1.48,-2.53 -1.91,-3.96h3.82c-0.43,1.43 -1.08,2.76 -1.91,3.96zM14.34,14L9.66,14c-0.09,-0.66 -0.16,-1.32 -0.16,-2 0,-0.68 0.07,-1.35 0.16,-2h4.68c0.09,0.65 0.16,1.32 0.16,2 0,0.68 -0.07,1.34 -0.16,2zM14.59,19.56c0.6,-1.11 1.06,-2.31 1.38,-3.56h2.95c-0.96,1.65 -2.49,2.93 -4.33,3.56zM16.36,14c0.08,-0.66 0.14,-1.32 0.14,-2 0,-0.68 -0.06,-1.34 -0.14,-2h3.38c0.16,0.64 0.26,1.31 0.26,2s-0.1,1.36 -0.26,2h-3.38z"/></svg></button><button class="gj-icon-btn" aria-label="نشانکها"><svg class="gj-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg></button><button class="gj-icon-btn" aria-label="دریافتها"><svg class="gj-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M5,20h14v-2H5v2zM19,9h-4V3H9v6H5l7,7 7,-7z"/></svg></button><button class="gj-icon-btn" aria-label="تنظیمات خواندن"><svg class="gj-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.07-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.07,0.94l-2.03,1.58c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"/></svg></button></div></div>
|
||||
</div>
|
||||
</body></html>
|
||||
159
design/components/bundle.css
Normal file
@ -0,0 +1,159 @@
|
||||
/* Ganjoor — static renditions of the app's Compose components. Values come from tokens.css. */
|
||||
.gj { font-family: var(--font-naskh); color: var(--on-surface); background: var(--surface); }
|
||||
.gj[lang="en"] { font-family: var(--font-libron); }
|
||||
.gj *, .gj *::before, .gj *::after { box-sizing: border-box; }
|
||||
.gj-icon { width: var(--icon); height: var(--icon); fill: currentColor; flex: none; display: block; }
|
||||
.gj-icon-btn { width: var(--touch); height: var(--touch); border: 0; background: none; color: var(--on-surface-variant); border-radius: var(--radius-full); display: inline-flex; align-items: center; justify-content: center; cursor: pointer; padding: 0; }
|
||||
.gj-icon-btn:hover { background: color-mix(in srgb, var(--on-surface-variant) 8%, transparent); }
|
||||
.gj-icon-btn:focus-visible, .gj-text-btn:focus-visible, .gj-chip:focus-visible, .gj-card:focus-visible, .gj-row:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
|
||||
|
||||
/* Top app bar: 64px, surface, title-large; nav icon on-surface, actions on-surface-variant. */
|
||||
.gj-topbar { height: 64px; display: flex; align-items: center; gap: var(--space-4); padding: 0 var(--space-4); background: var(--surface); }
|
||||
.gj-topbar .gj-nav { color: var(--on-surface); }
|
||||
.gj-topbar-title { flex: 1; min-width: 0; font-size: 22px; line-height: 28px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; padding-inline-start: var(--space-4); }
|
||||
.gj-topbar-actions { display: flex; }
|
||||
.gj-topbar .gj-on { color: var(--primary); }
|
||||
|
||||
/* Text button: primary label-large, pill, 40px. */
|
||||
.gj-text-btn { height: 40px; padding: 0 12px; border: 0; background: none; border-radius: var(--radius-full); color: var(--primary); font: 500 14px/20px var(--font-naskh); letter-spacing: 0.1px; display: inline-flex; align-items: center; gap: var(--space-4); cursor: pointer; }
|
||||
.gj[lang="en"] .gj-text-btn { font-family: var(--font-libron); }
|
||||
.gj-text-btn .gj-icon { width: var(--icon-inline); height: var(--icon-inline); }
|
||||
.gj-text-btn.is-neutral { color: var(--on-surface); }
|
||||
|
||||
/* Breadcrumbs: title-medium; ancestors primary and tappable, current on-surface, » separators muted. */
|
||||
.gj-crumbs { display: flex; flex-wrap: wrap; align-items: center; font-size: 16px; line-height: 24px; font-weight: 500; }
|
||||
.gj-crumbs a { color: var(--primary); text-decoration: none; cursor: pointer; }
|
||||
.gj-crumbs .gj-sep { color: var(--on-surface-variant); white-space: pre; }
|
||||
.gj-crumbs .gj-here { color: var(--on-surface); }
|
||||
.gj-metre { font-size: 12px; line-height: 16px; letter-spacing: .4px; color: var(--on-surface-variant); }
|
||||
|
||||
/* Couplet: two hemistichs stacked; first to the start, second to the end. */
|
||||
.gj-couplet { margin: var(--space-4) 0; padding: var(--space-8) var(--space-12); border-radius: var(--radius-md); background: var(--surface-container-high); }
|
||||
.gj-couplet.is-prose { margin: 0; padding: var(--space-6) 0; background: none; }
|
||||
.gj-verse { font-family: var(--font-naskh); font-size: 22px; line-height: 1.8; color: var(--on-surface); margin: 0; }
|
||||
.gj-couplet.is-nastaliq .gj-verse { font-family: var(--font-nastaliq); line-height: 2.4; }
|
||||
.gj-verse.is-first { text-align: start; }
|
||||
.gj-verse.is-second { text-align: end; }
|
||||
.gj-verse.is-centered { text-align: center; }
|
||||
.gj-verse.is-prose { text-align: justify; }
|
||||
.gj-couplet-summary { font-size: 12px; line-height: 16px; letter-spacing: .4px; color: var(--on-surface-variant); padding-top: var(--space-4); }
|
||||
.gj-couplet-toggle { display: flex; justify-content: flex-end; }
|
||||
.gj-couplet-toggle .gj-icon-btn { width: var(--touch-compact); height: var(--touch-compact); }
|
||||
.gj-couplet-toggle .gj-icon { width: var(--icon-small); height: var(--icon-small); }
|
||||
.gj-actions { display: flex; flex-wrap: wrap; gap: var(--space-8); }
|
||||
|
||||
/* Filter chips: 32px, radius-sm; selected = secondary-container with a check. */
|
||||
.gj-chips { display: flex; flex-wrap: wrap; gap: var(--space-8); }
|
||||
.gj-chip { height: 32px; padding: 0 16px; border-radius: var(--radius-sm); border: 1px solid var(--outline); background: transparent; color: var(--on-surface-variant); font: 500 14px/20px var(--font-naskh); letter-spacing: .1px; display: inline-flex; align-items: center; gap: var(--space-8); cursor: pointer; }
|
||||
.gj[lang="en"] .gj-chip { font-family: var(--font-libron); }
|
||||
.gj-chip[aria-pressed="true"] { background: var(--secondary-container); color: var(--on-secondary-container); border-color: transparent; padding-inline-start: 8px; }
|
||||
.gj-chip .gj-icon { width: var(--icon-inline); height: var(--icon-inline); }
|
||||
|
||||
/* Poet portrait disc: secondary-container with the initial underneath the photo. */
|
||||
.gj-portrait { border-radius: var(--radius-full); background: var(--secondary-container); color: var(--on-secondary-container); display: flex; align-items: center; justify-content: center; overflow: hidden; flex: none; position: relative; }
|
||||
.gj-portrait img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
||||
.gj-portrait.is-row { width: var(--avatar-row); height: var(--avatar-row); font-size: 16px; }
|
||||
.gj-portrait.is-card { width: var(--avatar-card); height: var(--avatar-card); font-size: 28px; line-height: 36px; }
|
||||
|
||||
/* Poet card: filled Card, radius-md, surface-container-highest, 12 padding, 8 gap. */
|
||||
.gj-poet-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(var(--poet-grid-min), 1fr)); gap: var(--space-12); }
|
||||
.gj-card { background: var(--surface-container-highest); color: var(--on-surface); border-radius: var(--radius-md); padding: var(--space-12); display: flex; flex-direction: column; align-items: center; gap: var(--space-8); cursor: pointer; }
|
||||
.gj-name { font-size: 16px; line-height: 24px; font-weight: 500; letter-spacing: .15px; text-align: center; display: flex; align-items: center; gap: var(--space-4); }
|
||||
.gj-pin { width: var(--icon-pin); height: var(--icon-pin); color: var(--primary); fill: currentColor; flex: none; }
|
||||
|
||||
/* List row: Material ListItem, 16 side padding, 56–72 tall. */
|
||||
.gj-row { display: flex; align-items: center; gap: var(--space-16); min-height: 72px; padding: var(--space-8) var(--space-16); background: var(--surface); cursor: pointer; }
|
||||
.gj-row-text { flex: 1; min-width: 0; }
|
||||
.gj-row-trailing { display: flex; align-items: center; gap: var(--space-4); }
|
||||
.gj-row-support { font-size: 14px; line-height: 20px; letter-spacing: .25px; color: var(--on-surface-variant); }
|
||||
|
||||
/* Download state, all in a 48px box so ticks and arrows line up. */
|
||||
.gj-dl { width: var(--touch); height: var(--touch); display: inline-flex; align-items: center; justify-content: center; }
|
||||
.gj-dl.is-saved { color: var(--downloaded); }
|
||||
.gj-dl.is-idle { color: var(--on-surface-variant); }
|
||||
.gj-ring { width: var(--icon-inline); height: var(--icon-inline); border-radius: 50%; border: var(--space-2) solid var(--surface-container-highest); border-top-color: var(--primary); }
|
||||
|
||||
/* Settings sheet pieces. */
|
||||
.gj-sheet { background: var(--surface-container-low); border-radius: var(--radius-xl) var(--radius-xl) 0 0; padding: var(--space-8) var(--space-20) var(--space-32); display: flex; flex-direction: column; gap: var(--space-8); }
|
||||
.gj-handle { width: 32px; height: 4px; border-radius: var(--radius-full); background: var(--on-surface-variant); opacity: .4; margin: var(--space-8) auto var(--space-8); }
|
||||
.gj-label { font-size: 14px; line-height: 20px; font-weight: 500; letter-spacing: .1px; color: var(--primary); padding-top: var(--space-12); }
|
||||
.gj-toggle { display: flex; align-items: center; gap: var(--space-16); padding-top: var(--space-12); }
|
||||
.gj-toggle-text { flex: 1; }
|
||||
.gj-toggle-title { font-size: 16px; line-height: 24px; color: var(--on-surface); }
|
||||
.gj-toggle-note { font-size: 12px; line-height: 16px; letter-spacing: .4px; color: var(--on-surface-variant); }
|
||||
.gj-switch { width: 52px; height: 32px; border-radius: var(--radius-full); border: 2px solid var(--outline); background: var(--surface-container-highest); position: relative; flex: none; cursor: pointer; padding: 0; }
|
||||
.gj-switch::after { content: ""; position: absolute; top: 50%; inset-inline-start: 6px; width: 16px; height: 16px; margin-top: -8px; border-radius: 50%; background: var(--outline); }
|
||||
.gj-switch[aria-checked="true"] { background: var(--primary); border-color: var(--primary); }
|
||||
.gj-switch[aria-checked="true"]::after { inset-inline-start: auto; inset-inline-end: 2px; width: 24px; height: 24px; margin-top: -12px; background: var(--on-primary); }
|
||||
|
||||
/* Bottom search bar: tonal surface, outlined field. */
|
||||
.gj-searchbar { background: var(--surface-tonal-3); padding: var(--space-8) var(--space-12); }
|
||||
.gj-field { height: 56px; border: 1px solid var(--outline); border-radius: var(--radius-xs); display: flex; align-items: center; gap: var(--space-16); padding: 0 var(--space-12); color: var(--on-surface-variant); font-size: 16px; line-height: 24px; letter-spacing: .5px; }
|
||||
.gj-search-poems { display: flex; align-items: center; gap: var(--space-16); min-height: 56px; padding: 0 var(--space-16); color: var(--on-surface); }
|
||||
.gj-search-poems .gj-icon { color: var(--primary); }
|
||||
|
||||
.gj-divider { height: 1px; background: var(--outline-variant); border: 0; margin: 0; }
|
||||
.gj-ai-note { font-size: 11px; line-height: 16px; font-weight: 500; letter-spacing: .5px; color: var(--primary); padding-top: var(--space-12); }
|
||||
.gj-summary { font-size: 14px; line-height: 20px; letter-spacing: .25px; color: var(--on-surface-variant); }
|
||||
|
||||
/* ── Large screens: ColumnBrowser (tablets, unfolded foldables; width ≥ 600) ──
|
||||
Column browser in RTL: poets rail on the far right, then books, then chapters / poems,
|
||||
and the open poem fills what is left on the left. */
|
||||
.gj-ts { display: flex; height: 100%; overflow: hidden; background: var(--surface); }
|
||||
.gj-ts-home { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.gj-ts.is-split .gj-ts-home { display: none; }
|
||||
.gj-ts-scroll { overflow-y: auto; min-height: 0; overscroll-behavior: contain; }
|
||||
.gj-ts-cols { display: none; flex: none; height: 100%; }
|
||||
.gj-ts.is-split .gj-ts-cols { display: flex; }
|
||||
.gj-ts-col { flex: none; width: 224px; display: flex; flex-direction: column; min-height: 0; border-inline-end: 1px solid var(--outline-variant); background: var(--surface-container-low); transition: width 300ms cubic-bezier(.2,0,0,1); }
|
||||
.gj-ts-col.is-new { animation: gj-ts-in 400ms cubic-bezier(.05,.7,.1,1) both; }
|
||||
/* Columns narrow as you go deeper: the newest list is widest, older ones give their room to the poem. */
|
||||
.gj-ts-col[data-dist="1"] { width: 168px; }
|
||||
.gj-ts-col[data-dist="2"] { width: 132px; }
|
||||
.gj-ts-col[data-dist="1"] .gj-ts-item, .gj-ts-col[data-dist="2"] .gj-ts-item { font-size: 14px; line-height: 20px; padding: var(--space-8); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.gj-ts-col[data-dist="1"] .gj-ts-item small, .gj-ts-col[data-dist="2"] .gj-ts-item small, .gj-ts-col[data-dist="1"] .gj-ts-item::after, .gj-ts-col[data-dist="2"] .gj-ts-item::after { display: none; }
|
||||
.gj-ts-col[data-dist="1"] .gj-ts-colhead, .gj-ts-col[data-dist="2"] .gj-ts-colhead { padding: 0 var(--space-8); }
|
||||
.gj-ts-col[data-dist="1"] .gj-ts-colhead b, .gj-ts-col[data-dist="2"] .gj-ts-colhead b { font-size: 14px; line-height: 20px; }
|
||||
.gj-ts-col.is-rail { width: 96px; background: var(--surface-container); }
|
||||
.gj-ts.is-deep .gj-ts-col.is-rail { width: 80px; }
|
||||
.gj-ts.is-deep .gj-ts-poet .gj-portrait { width: 44px; height: 44px; font-size: 18px; }
|
||||
.gj-ts-poet { overflow-wrap: anywhere; }
|
||||
@keyframes gj-ts-in { from { opacity: 0; transform: translateX(-32px); } }
|
||||
.gj-ts-col.is-rail.is-arriving { animation-name: gj-ts-rail; }
|
||||
@keyframes gj-ts-rail { from { width: 100vw; } }
|
||||
.gj-ts-colhead { flex: none; min-height: 56px; display: flex; flex-direction: column; justify-content: center; padding: 0 var(--space-16); border-bottom: 1px solid var(--outline-variant); }
|
||||
.gj-ts-colhead small { color: var(--on-surface-variant); font: 500 11px/16px var(--font-naskh); letter-spacing: .5px; }
|
||||
.gj-ts-colhead b { color: var(--on-surface); font: 500 16px/24px var(--font-naskh); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.gj-ts-list { padding: var(--space-8); flex: 1; }
|
||||
|
||||
/* Poets rail: portrait with the name under it. */
|
||||
.gj-ts-poet { display: flex; flex-direction: column; align-items: center; gap: var(--space-4); width: 100%; padding: var(--space-8) var(--space-4); border: 0; background: none; border-radius: var(--radius-md); color: var(--on-surface); font: 500 12px/16px var(--font-naskh); letter-spacing: .5px; text-align: center; cursor: pointer; }
|
||||
.gj-ts-poet .gj-portrait { width: 56px; height: 56px; font-size: 22px; }
|
||||
.gj-ts-poet[aria-current="true"] { background: var(--secondary-container); color: var(--on-secondary-container); }
|
||||
.gj-ts-poet[aria-current="true"] .gj-portrait { outline: 2px solid var(--primary); outline-offset: 2px; }
|
||||
|
||||
/* Book / chapter / poem rows. */
|
||||
.gj-ts-item { display: block; width: 100%; min-height: 48px; padding: var(--space-8) var(--space-12); border: 0; background: none; color: var(--on-surface); font: 400 16px/24px var(--font-naskh); text-align: start; cursor: pointer; border-radius: var(--radius-md); }
|
||||
.gj-ts-item small { display: block; color: var(--on-surface-variant); font-size: 12px; line-height: 16px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.gj-ts-item:hover, .gj-ts-poet:hover { background: color-mix(in srgb, var(--on-surface) 6%, transparent); }
|
||||
.gj-ts-item[aria-current="true"] { background: var(--secondary-container); color: var(--on-secondary-container); }
|
||||
.gj-ts-item[aria-current="true"] small { color: var(--on-secondary-container); }
|
||||
.gj-ts-item.has-kids::after { content: "‹"; float: inline-end; color: var(--on-surface-variant); }
|
||||
|
||||
/* Focus mode: the columns fold away and the poem takes the whole width, still centred. */
|
||||
.gj-ts.is-focus .gj-ts-cols { display: none; }
|
||||
.gj-ts-toggle[aria-pressed="false"] { color: var(--primary); }
|
||||
|
||||
/* The open work. */
|
||||
.gj-ts-main { flex: 1; min-width: 0; display: flex; flex-direction: column; animation: gj-ts-in 400ms cubic-bezier(.05,.7,.1,1) both; }
|
||||
.gj-ts-content { flex: 1; padding: var(--space-8) var(--space-32) var(--space-32); }
|
||||
.gj-ts-measure { max-width: 680px; margin-inline: auto; }
|
||||
.gj-ts-main { container-type: inline-size; }
|
||||
/* Hemistichs side by side only when the poem column has room; otherwise they stack as on a phone. */
|
||||
.gj-ts .gj-couplet.is-wide .gj-verse.is-first { text-align: start; }
|
||||
.gj-ts .gj-couplet.is-wide .gj-verse.is-second { text-align: end; }
|
||||
@container (min-width: 640px) {
|
||||
.gj-ts .gj-couplet.is-wide { display: grid; grid-template-columns: 1fr 1fr; column-gap: var(--space-32); align-items: baseline; }
|
||||
}
|
||||
.gj-ts-books { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: var(--space-12); margin-top: var(--space-16); }
|
||||
.gj-ts-books .gj-card { align-items: flex-start; text-align: start; }
|
||||
859
design/tokens.json
Normal file
@ -0,0 +1,859 @@
|
||||
{
|
||||
"name": "Ganjoor",
|
||||
"version": 1,
|
||||
"meta": {
|
||||
"source": "github",
|
||||
"repo": "anas-rashid/ganjoorandroid",
|
||||
"ref": "main@50bbe0b",
|
||||
"package": "app",
|
||||
"paths": {
|
||||
"tokens": [
|
||||
"app/src/main/java/com/ganjoor/android/ui/theme/Theme.kt",
|
||||
"app/src/main/java/com/ganjoor/android/ui/theme/Type.kt",
|
||||
"app/src/main/res/values/colors.xml"
|
||||
],
|
||||
"fonts": [
|
||||
"app/src/main/res/font/"
|
||||
],
|
||||
"assets": [
|
||||
"app/src/main/res/drawable/",
|
||||
"app/src/main/res/mipmap-xxxhdpi/",
|
||||
"fastlane/metadata/android/fa/images/phoneScreenshots/"
|
||||
],
|
||||
"docs": [
|
||||
"README.md"
|
||||
]
|
||||
},
|
||||
"synced": "2026-10-07"
|
||||
},
|
||||
"color": {
|
||||
"themes": [
|
||||
{
|
||||
"id": "light",
|
||||
"name": "Light"
|
||||
},
|
||||
{
|
||||
"id": "dark",
|
||||
"name": "Dark"
|
||||
},
|
||||
{
|
||||
"id": "sepia",
|
||||
"name": "Sepia"
|
||||
},
|
||||
{
|
||||
"id": "sepia-night",
|
||||
"name": "Sepia night"
|
||||
},
|
||||
{
|
||||
"id": "dark-oled",
|
||||
"name": "Dark · OLED black"
|
||||
},
|
||||
{
|
||||
"id": "sepia-night-oled",
|
||||
"name": "Sepia night · OLED black"
|
||||
}
|
||||
],
|
||||
"tokens": [
|
||||
{
|
||||
"name": "primary",
|
||||
"value": {
|
||||
"light": "#00696e",
|
||||
"dark": "#80d4da",
|
||||
"sepia": "#7a4e24",
|
||||
"sepia-night": "#d9a96c",
|
||||
"dark-oled": "#80d4da",
|
||||
"sepia-night-oled": "#d9a96c"
|
||||
},
|
||||
"usage": "Turquoise (sepia: walnut). Tappable crumbs in breadcrumbs, section labels in the settings sheet, the pin mark, a saved bookmark heart, TextButton labels, \"AI-generated\" note. 6.3:1+ on surface in every theme."
|
||||
},
|
||||
{
|
||||
"name": "on-primary",
|
||||
"value": {
|
||||
"light": "#ffffff",
|
||||
"dark": "#003739",
|
||||
"sepia": "#ffffff",
|
||||
"sepia-night": "#3a2510",
|
||||
"dark-oled": "#003739",
|
||||
"sepia-night-oled": "#3a2510"
|
||||
},
|
||||
"usage": "Text and icons on a primary fill: Switch thumb when on, filled Button labels."
|
||||
},
|
||||
{
|
||||
"name": "primary-container",
|
||||
"value": {
|
||||
"light": "#9cf1f6",
|
||||
"dark": "#004f53",
|
||||
"sepia": "#e8d3b5",
|
||||
"sepia-night": "#53391c",
|
||||
"dark-oled": "#004f53",
|
||||
"sepia-night-oled": "#53391c"
|
||||
},
|
||||
"usage": "Tonal turquoise fill for emphasised containers (Material defaults: filled-tonal surfaces). Rarely used directly."
|
||||
},
|
||||
{
|
||||
"name": "on-primary-container",
|
||||
"value": {
|
||||
"light": "#002021",
|
||||
"dark": "#9cf1f6",
|
||||
"sepia": "#2b1700",
|
||||
"sepia-night": "#f5debe",
|
||||
"dark-oled": "#9cf1f6",
|
||||
"sepia-night-oled": "#f5debe"
|
||||
},
|
||||
"usage": "Text on primary-container."
|
||||
},
|
||||
{
|
||||
"name": "secondary",
|
||||
"value": {
|
||||
"light": "#8b5000",
|
||||
"dark": "#ffb873",
|
||||
"sepia": "#6b5b3e",
|
||||
"sepia-night": "#cfbe98",
|
||||
"dark-oled": "#ffb873",
|
||||
"sepia-night-oled": "#cfbe98"
|
||||
},
|
||||
"usage": "Saffron accent (sepia: olive-brown). The tilework counterpart to primary; reserve for accents, never body text."
|
||||
},
|
||||
{
|
||||
"name": "on-secondary",
|
||||
"value": {
|
||||
"light": "#ffffff",
|
||||
"dark": "#4a2800",
|
||||
"sepia": "#ffffff",
|
||||
"sepia-night": "#362c14",
|
||||
"dark-oled": "#4a2800",
|
||||
"sepia-night-oled": "#362c14"
|
||||
},
|
||||
"usage": "Text on a secondary fill."
|
||||
},
|
||||
{
|
||||
"name": "secondary-container",
|
||||
"value": {
|
||||
"light": "#ffdcbe",
|
||||
"dark": "#693c00",
|
||||
"sepia": "#eadfc4",
|
||||
"sepia-night": "#4d4228",
|
||||
"dark-oled": "#693c00",
|
||||
"sepia-night-oled": "#4d4228"
|
||||
},
|
||||
"usage": "Peach fill: a selected FilterChip, and the disc behind a poet portrait (shows the initial when no portrait exists)."
|
||||
},
|
||||
{
|
||||
"name": "on-secondary-container",
|
||||
"value": {
|
||||
"light": "#2c1600",
|
||||
"dark": "#ffdcbe",
|
||||
"sepia": "#231a05",
|
||||
"sepia-night": "#ebdcb7",
|
||||
"dark-oled": "#ffdcbe",
|
||||
"sepia-night-oled": "#ebdcb7"
|
||||
},
|
||||
"usage": "Text and check-mark on secondary-container: selected chip label, poet initial. 7.2:1+ in every theme."
|
||||
},
|
||||
{
|
||||
"name": "background",
|
||||
"value": {
|
||||
"light": "#fafdfc",
|
||||
"dark": "#191c1c",
|
||||
"sepia": "#f6ecd9",
|
||||
"sepia-night": "#1c1710",
|
||||
"dark-oled": "#000000",
|
||||
"sepia-night-oled": "#000000"
|
||||
},
|
||||
"usage": "Window and splash background (res/values/colors.xml window_background mirrors light and dark)."
|
||||
},
|
||||
{
|
||||
"name": "on-background",
|
||||
"value": {
|
||||
"light": "#191c1c",
|
||||
"dark": "#e0e3e2",
|
||||
"sepia": "#3a2e1e",
|
||||
"sepia-night": "#e6dac4",
|
||||
"dark-oled": "#e0e3e2",
|
||||
"sepia-night-oled": "#e6dac4"
|
||||
},
|
||||
"usage": "Text on background."
|
||||
},
|
||||
{
|
||||
"name": "surface",
|
||||
"value": {
|
||||
"light": "#fafdfc",
|
||||
"dark": "#191c1c",
|
||||
"sepia": "#f1e5ce",
|
||||
"sepia-night": "#241d14",
|
||||
"dark-oled": "#000000",
|
||||
"sepia-night-oled": "#000000"
|
||||
},
|
||||
"usage": "Page ground behind lists, the reader column, top app bar and sheets."
|
||||
},
|
||||
{
|
||||
"name": "on-surface",
|
||||
"value": {
|
||||
"light": "#191c1c",
|
||||
"dark": "#e0e3e2",
|
||||
"sepia": "#3a2e1e",
|
||||
"sepia-night": "#e6dac4",
|
||||
"dark-oled": "#e0e3e2",
|
||||
"sepia-night-oled": "#e6dac4"
|
||||
},
|
||||
"usage": "Primary text on surface: poem verses, list headlines, the current breadcrumb. 10.6:1+ in every theme."
|
||||
},
|
||||
{
|
||||
"name": "surface-variant",
|
||||
"value": {
|
||||
"light": "#dae4e4",
|
||||
"dark": "#3f4949",
|
||||
"sepia": "#e5d8bc",
|
||||
"sepia-night": "#3b3225",
|
||||
"dark-oled": "#111515",
|
||||
"sepia-night-oled": "#0f0c07"
|
||||
},
|
||||
"usage": "Muted fill for variant surfaces (Material defaults: slider inactive track, chip disabled)."
|
||||
},
|
||||
{
|
||||
"name": "on-surface-variant",
|
||||
"value": {
|
||||
"light": "#3f4949",
|
||||
"dark": "#bec8c8",
|
||||
"sepia": "#5a4c35",
|
||||
"sepia-night": "#ccbfa6",
|
||||
"dark-oled": "#bec8c8",
|
||||
"sepia-night-oled": "#ccbfa6"
|
||||
},
|
||||
"usage": "Secondary text and icons: metre line, couplet summaries, setting notes, \"»\" separators, action icons. 5.6:1+ on surface and surface-container-highest in every theme."
|
||||
},
|
||||
{
|
||||
"name": "surface-bright",
|
||||
"value": {
|
||||
"light": "#fafdfc",
|
||||
"dark": "#3f4242",
|
||||
"sepia": "#f8f0e0",
|
||||
"sepia-night": "#433a2d",
|
||||
"dark-oled": "#161717",
|
||||
"sepia-night-oled": "#17130d"
|
||||
},
|
||||
"usage": "Brightest surface tone; OLED keeps it 46% of the way to black."
|
||||
},
|
||||
{
|
||||
"name": "surface-dim",
|
||||
"value": {
|
||||
"light": "#d9dddc",
|
||||
"dark": "#191c1c",
|
||||
"sepia": "#dbd0b8",
|
||||
"sepia-night": "#1c1710",
|
||||
"dark-oled": "#000000",
|
||||
"sepia-night-oled": "#000000"
|
||||
},
|
||||
"usage": "Dimmest surface tone."
|
||||
},
|
||||
{
|
||||
"name": "surface-container-lowest",
|
||||
"value": {
|
||||
"light": "#ffffff",
|
||||
"dark": "#141616",
|
||||
"sepia": "#fffaf0",
|
||||
"sepia-night": "#17120c",
|
||||
"dark-oled": "#000000",
|
||||
"sepia-night-oled": "#000000"
|
||||
},
|
||||
"usage": "Lowest container tone."
|
||||
},
|
||||
{
|
||||
"name": "surface-container-low",
|
||||
"value": {
|
||||
"light": "#f3f7f6",
|
||||
"dark": "#212424",
|
||||
"sepia": "#f2e8d4",
|
||||
"sepia-night": "#241d14",
|
||||
"dark-oled": "#000000",
|
||||
"sepia-night-oled": "#000000"
|
||||
},
|
||||
"usage": "Bottom sheet and elevated card ground (Material default for ModalBottomSheet)."
|
||||
},
|
||||
{
|
||||
"name": "surface-container",
|
||||
"value": {
|
||||
"light": "#eef1f0",
|
||||
"dark": "#252828",
|
||||
"sepia": "#ece1ca",
|
||||
"sepia-night": "#2a2218",
|
||||
"dark-oled": "#010101",
|
||||
"sepia-night-oled": "#010101"
|
||||
},
|
||||
"usage": "Default container tone: navigation bar, menus."
|
||||
},
|
||||
{
|
||||
"name": "surface-container-high",
|
||||
"value": {
|
||||
"light": "#e8eceb",
|
||||
"dark": "#2f3333",
|
||||
"sepia": "#e6dac0",
|
||||
"sepia-night": "#352c21",
|
||||
"dark-oled": "#040505",
|
||||
"sepia-night-oled": "#050402"
|
||||
},
|
||||
"usage": "Dialogs and dropdown menus."
|
||||
},
|
||||
{
|
||||
"name": "surface-container-highest",
|
||||
"value": {
|
||||
"light": "#e2e6e5",
|
||||
"dark": "#3a3e3e",
|
||||
"sepia": "#e0d3b6",
|
||||
"sepia-night": "#40372b",
|
||||
"dark-oled": "#0d0f0f",
|
||||
"sepia-night-oled": "#100c08"
|
||||
},
|
||||
"usage": "Filled Card ground: poet cards in the grid; Switch track when off."
|
||||
},
|
||||
{
|
||||
"name": "outline",
|
||||
"value": {
|
||||
"light": "#6f7979",
|
||||
"dark": "#899393",
|
||||
"sepia": "#8c7a5c",
|
||||
"sepia-night": "#968a72",
|
||||
"dark-oled": "#899393",
|
||||
"sepia-night-oled": "#968a72"
|
||||
},
|
||||
"usage": "1px borders: OutlinedTextField, unselected FilterChip outline in focus, Switch border when off. Sepia is 3.3:1 on surface, still above the 3:1 floor for control borders."
|
||||
},
|
||||
{
|
||||
"name": "outline-variant",
|
||||
"value": {
|
||||
"light": "#cac4d0",
|
||||
"dark": "#49454f",
|
||||
"sepia": "#cac4d0",
|
||||
"sepia-night": "#49454f",
|
||||
"dark-oled": "#49454f",
|
||||
"sepia-night-oled": "#49454f"
|
||||
},
|
||||
"usage": "Material 3 default, not overridden by any scheme (a faintly purple grey): HorizontalDivider and the unselected FilterChip border. Decorative only; never the sole boundary of a control."
|
||||
},
|
||||
{
|
||||
"name": "error",
|
||||
"value": {
|
||||
"light": "#b3261e",
|
||||
"dark": "#f2b8b5",
|
||||
"sepia": "#b3261e",
|
||||
"sepia-night": "#f2b8b5",
|
||||
"dark-oled": "#f2b8b5",
|
||||
"sepia-night-oled": "#f2b8b5"
|
||||
},
|
||||
"usage": "Material 3 default, not overridden. Load-failure and assistant error text on surface."
|
||||
},
|
||||
{
|
||||
"name": "on-error",
|
||||
"value": {
|
||||
"light": "#ffffff",
|
||||
"dark": "#601410",
|
||||
"sepia": "#ffffff",
|
||||
"sepia-night": "#601410",
|
||||
"dark-oled": "#601410",
|
||||
"sepia-night-oled": "#601410"
|
||||
},
|
||||
"usage": "Material 3 default. Text on an error fill."
|
||||
},
|
||||
{
|
||||
"name": "downloaded",
|
||||
"value": {
|
||||
"light": "#2e7d32",
|
||||
"dark": "#81c784",
|
||||
"sepia": "#2e7d32",
|
||||
"sepia-night": "#81c784",
|
||||
"dark-oled": "#81c784",
|
||||
"sepia-night-oled": "#81c784"
|
||||
},
|
||||
"usage": "ColorScheme.downloaded in Theme.kt: the CheckCircle beside a poet already saved on the device. Picked by surface luminance (<0.5 → light green). Always paired with the tick shape, never colour alone. 4.1:1+ on surface in every theme."
|
||||
},
|
||||
{
|
||||
"name": "surface-tonal-3",
|
||||
"value": {
|
||||
"light": "#e5f1f0",
|
||||
"dark": "#212b2c",
|
||||
"sepia": "#e7d9c0",
|
||||
"sepia-night": "#33291b",
|
||||
"dark-oled": "#0b1112",
|
||||
"sepia-night-oled": "#120e09"
|
||||
},
|
||||
"usage": "Derived, not declared: Material 3 tonal elevation 3dp (primary at 8.2% over surface). The bottom search bar's Surface on the poets screen."
|
||||
}
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"fonts": [
|
||||
{
|
||||
"family": "Noto Naskh Arabic",
|
||||
"file": "fonts/NotoNaskhArabic.ttf",
|
||||
"weight": "400 700",
|
||||
"style": "normal"
|
||||
},
|
||||
{
|
||||
"family": "Noto Nastaliq Urdu",
|
||||
"file": "fonts/NotoNastaliqUrdu.ttf",
|
||||
"weight": "400 700",
|
||||
"style": "normal"
|
||||
},
|
||||
{
|
||||
"family": "Libron",
|
||||
"file": "fonts/Libron-Regular.ttf",
|
||||
"weight": "400",
|
||||
"style": "normal"
|
||||
},
|
||||
{
|
||||
"family": "Libron",
|
||||
"file": "fonts/Libron-Bold.ttf",
|
||||
"weight": "700",
|
||||
"style": "normal"
|
||||
}
|
||||
],
|
||||
"families": {
|
||||
"naskh": "\"Noto Naskh Arabic\", \"Noto Naskh\", serif",
|
||||
"nastaliq": "\"Noto Nastaliq Urdu\", \"Noto Naskh Arabic\", serif",
|
||||
"libron": "Libron, Georgia, \"Times New Roman\", serif"
|
||||
},
|
||||
"groups": [
|
||||
{
|
||||
"name": "Reading",
|
||||
"family": "naskh",
|
||||
"styles": [
|
||||
{
|
||||
"name": "poem-naskh",
|
||||
"family": "naskh",
|
||||
"fontSize": "22px",
|
||||
"lineHeight": 1.8,
|
||||
"fontWeight": 400,
|
||||
"sample": "الا یا ایها الساقی ادر کأسا و ناولها",
|
||||
"usage": "The poem itself, default font. Size 14–40px in 2px steps (default 22); weight Regular / Medium / Semibold / Bold chosen by the reader; leading always 1.8× the size."
|
||||
},
|
||||
{
|
||||
"name": "poem-nastaliq",
|
||||
"family": "nastaliq",
|
||||
"fontSize": "22px",
|
||||
"lineHeight": 2.4,
|
||||
"fontWeight": 400,
|
||||
"sample": "الا یا ایها الساقی ادر کأسا و ناولها",
|
||||
"usage": "The poem when the reader picks nastaliq. Leading 2.4× — nastaliq stacks diagonally and has deep descenders, so tighter leading clips the swashes. Same size and weight range as naskh."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Interface — Persian & Urdu (Naskh)",
|
||||
"family": "naskh",
|
||||
"styles": [
|
||||
{
|
||||
"name": "display-large",
|
||||
"fontSize": "57px",
|
||||
"lineHeight": "64px",
|
||||
"fontWeight": 400,
|
||||
"letterSpacing": "-0.25px",
|
||||
"usage": "Material 3 default scale; carried for completeness, not used on any current screen.",
|
||||
"sample": "گنجور — خوانندهٔ شعر پارسی"
|
||||
},
|
||||
{
|
||||
"name": "display-medium",
|
||||
"fontSize": "45px",
|
||||
"lineHeight": "52px",
|
||||
"fontWeight": 400,
|
||||
"letterSpacing": "0px",
|
||||
"usage": "Material 3 default scale; carried for completeness, not used on any current screen.",
|
||||
"sample": "گنجور — خوانندهٔ شعر پارسی"
|
||||
},
|
||||
{
|
||||
"name": "display-small",
|
||||
"fontSize": "36px",
|
||||
"lineHeight": "44px",
|
||||
"fontWeight": 400,
|
||||
"letterSpacing": "0px",
|
||||
"usage": "Material 3 default scale; carried for completeness, not used on any current screen.",
|
||||
"sample": "گنجور — خوانندهٔ شعر پارسی"
|
||||
},
|
||||
{
|
||||
"name": "headline-large",
|
||||
"fontSize": "32px",
|
||||
"lineHeight": "40px",
|
||||
"fontWeight": 400,
|
||||
"letterSpacing": "0px",
|
||||
"usage": "Material 3 default scale; carried for completeness, not used on any current screen.",
|
||||
"sample": "گنجور — خوانندهٔ شعر پارسی"
|
||||
},
|
||||
{
|
||||
"name": "headline-medium",
|
||||
"fontSize": "28px",
|
||||
"lineHeight": "36px",
|
||||
"fontWeight": 400,
|
||||
"letterSpacing": "0px",
|
||||
"usage": "A poet's initial inside the 84px portrait disc when no portrait loads.",
|
||||
"sample": "گنجور — خوانندهٔ شعر پارسی"
|
||||
},
|
||||
{
|
||||
"name": "headline-small",
|
||||
"fontSize": "24px",
|
||||
"lineHeight": "32px",
|
||||
"fontWeight": 400,
|
||||
"letterSpacing": "0px",
|
||||
"usage": "Material 3 default scale; carried for completeness, not used on any current screen.",
|
||||
"sample": "گنجور — خوانندهٔ شعر پارسی"
|
||||
},
|
||||
{
|
||||
"name": "title-large",
|
||||
"fontSize": "22px",
|
||||
"lineHeight": "28px",
|
||||
"fontWeight": 400,
|
||||
"letterSpacing": "0px",
|
||||
"usage": "Top app bar titles (poem title, app name گنجور).",
|
||||
"sample": "گنجور — خوانندهٔ شعر پارسی"
|
||||
},
|
||||
{
|
||||
"name": "title-medium",
|
||||
"fontSize": "16px",
|
||||
"lineHeight": "24px",
|
||||
"fontWeight": 500,
|
||||
"letterSpacing": "0.15px",
|
||||
"usage": "Poet names on cards and rows; breadcrumbs.",
|
||||
"sample": "گنجور — خوانندهٔ شعر پارسی"
|
||||
},
|
||||
{
|
||||
"name": "title-small",
|
||||
"fontSize": "14px",
|
||||
"lineHeight": "20px",
|
||||
"fontWeight": 500,
|
||||
"letterSpacing": "0.1px",
|
||||
"usage": "Section labels in the reading settings sheet, in primary.",
|
||||
"sample": "گنجور — خوانندهٔ شعر پارسی"
|
||||
},
|
||||
{
|
||||
"name": "body-large",
|
||||
"fontSize": "16px",
|
||||
"lineHeight": "24px",
|
||||
"fontWeight": 400,
|
||||
"letterSpacing": "0.5px",
|
||||
"usage": "List item headlines and setting titles (Material default text).",
|
||||
"sample": "گنجور — خوانندهٔ شعر پارسی"
|
||||
},
|
||||
{
|
||||
"name": "body-medium",
|
||||
"fontSize": "14px",
|
||||
"lineHeight": "20px",
|
||||
"fontWeight": 400,
|
||||
"letterSpacing": "0.25px",
|
||||
"usage": "Poem summary, list supporting text, empty states.",
|
||||
"sample": "گنجور — خوانندهٔ شعر پارسی"
|
||||
},
|
||||
{
|
||||
"name": "body-small",
|
||||
"fontSize": "12px",
|
||||
"lineHeight": "16px",
|
||||
"fontWeight": 400,
|
||||
"letterSpacing": "0.4px",
|
||||
"usage": "Secondary notes in on-surface-variant: metre, couplet summaries, setting notes, source line, pin hint.",
|
||||
"sample": "گنجور — خوانندهٔ شعر پارسی"
|
||||
},
|
||||
{
|
||||
"name": "label-large",
|
||||
"fontSize": "14px",
|
||||
"lineHeight": "20px",
|
||||
"fontWeight": 500,
|
||||
"letterSpacing": "0.1px",
|
||||
"usage": "Buttons, TextButtons and chip labels.",
|
||||
"sample": "گنجور — خوانندهٔ شعر پارسی"
|
||||
},
|
||||
{
|
||||
"name": "label-medium",
|
||||
"fontSize": "12px",
|
||||
"lineHeight": "16px",
|
||||
"fontWeight": 500,
|
||||
"letterSpacing": "0.5px",
|
||||
"usage": "Small badges and captions.",
|
||||
"sample": "گنجور — خوانندهٔ شعر پارسی"
|
||||
},
|
||||
{
|
||||
"name": "label-small",
|
||||
"fontSize": "11px",
|
||||
"lineHeight": "16px",
|
||||
"fontWeight": 500,
|
||||
"letterSpacing": "0.5px",
|
||||
"usage": "The \"AI-generated by Ganjoor\" note above a summary, in primary.",
|
||||
"sample": "گنجور — خوانندهٔ شعر پارسی"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Interface — English (Libron)",
|
||||
"family": "libron",
|
||||
"styles": [
|
||||
{
|
||||
"name": "en-display-large",
|
||||
"fontSize": "57px",
|
||||
"lineHeight": "64px",
|
||||
"fontWeight": 400,
|
||||
"letterSpacing": "-0.25px",
|
||||
"usage": "Material 3 default scale; carried for completeness, not used on any current screen.",
|
||||
"sample": "Search poems for “Hafez”"
|
||||
},
|
||||
{
|
||||
"name": "en-display-medium",
|
||||
"fontSize": "45px",
|
||||
"lineHeight": "52px",
|
||||
"fontWeight": 400,
|
||||
"letterSpacing": "0px",
|
||||
"usage": "Material 3 default scale; carried for completeness, not used on any current screen.",
|
||||
"sample": "Search poems for “Hafez”"
|
||||
},
|
||||
{
|
||||
"name": "en-display-small",
|
||||
"fontSize": "36px",
|
||||
"lineHeight": "44px",
|
||||
"fontWeight": 400,
|
||||
"letterSpacing": "0px",
|
||||
"usage": "Material 3 default scale; carried for completeness, not used on any current screen.",
|
||||
"sample": "Search poems for “Hafez”"
|
||||
},
|
||||
{
|
||||
"name": "en-headline-large",
|
||||
"fontSize": "32px",
|
||||
"lineHeight": "40px",
|
||||
"fontWeight": 400,
|
||||
"letterSpacing": "0px",
|
||||
"usage": "Material 3 default scale; carried for completeness, not used on any current screen.",
|
||||
"sample": "Search poems for “Hafez”"
|
||||
},
|
||||
{
|
||||
"name": "en-headline-medium",
|
||||
"fontSize": "28px",
|
||||
"lineHeight": "36px",
|
||||
"fontWeight": 400,
|
||||
"letterSpacing": "0px",
|
||||
"usage": "A poet's initial inside the 84px portrait disc when no portrait loads.",
|
||||
"sample": "Search poems for “Hafez”"
|
||||
},
|
||||
{
|
||||
"name": "en-headline-small",
|
||||
"fontSize": "24px",
|
||||
"lineHeight": "32px",
|
||||
"fontWeight": 400,
|
||||
"letterSpacing": "0px",
|
||||
"usage": "Material 3 default scale; carried for completeness, not used on any current screen.",
|
||||
"sample": "Search poems for “Hafez”"
|
||||
},
|
||||
{
|
||||
"name": "en-title-large",
|
||||
"fontSize": "22px",
|
||||
"lineHeight": "28px",
|
||||
"fontWeight": 400,
|
||||
"letterSpacing": "0px",
|
||||
"usage": "Top app bar titles (poem title, app name گنجور).",
|
||||
"sample": "Search poems for “Hafez”"
|
||||
},
|
||||
{
|
||||
"name": "en-title-medium",
|
||||
"fontSize": "16px",
|
||||
"lineHeight": "24px",
|
||||
"fontWeight": 500,
|
||||
"letterSpacing": "0.15px",
|
||||
"usage": "Poet names on cards and rows; breadcrumbs.",
|
||||
"sample": "Search poems for “Hafez”"
|
||||
},
|
||||
{
|
||||
"name": "en-title-small",
|
||||
"fontSize": "14px",
|
||||
"lineHeight": "20px",
|
||||
"fontWeight": 500,
|
||||
"letterSpacing": "0.1px",
|
||||
"usage": "Section labels in the reading settings sheet, in primary.",
|
||||
"sample": "Search poems for “Hafez”"
|
||||
},
|
||||
{
|
||||
"name": "en-body-large",
|
||||
"fontSize": "16px",
|
||||
"lineHeight": "24px",
|
||||
"fontWeight": 400,
|
||||
"letterSpacing": "0.5px",
|
||||
"usage": "List item headlines and setting titles (Material default text).",
|
||||
"sample": "Search poems for “Hafez”"
|
||||
},
|
||||
{
|
||||
"name": "en-body-medium",
|
||||
"fontSize": "14px",
|
||||
"lineHeight": "20px",
|
||||
"fontWeight": 400,
|
||||
"letterSpacing": "0.25px",
|
||||
"usage": "Poem summary, list supporting text, empty states.",
|
||||
"sample": "Search poems for “Hafez”"
|
||||
},
|
||||
{
|
||||
"name": "en-body-small",
|
||||
"fontSize": "12px",
|
||||
"lineHeight": "16px",
|
||||
"fontWeight": 400,
|
||||
"letterSpacing": "0.4px",
|
||||
"usage": "Secondary notes in on-surface-variant: metre, couplet summaries, setting notes, source line, pin hint.",
|
||||
"sample": "Search poems for “Hafez”"
|
||||
},
|
||||
{
|
||||
"name": "en-label-large",
|
||||
"fontSize": "14px",
|
||||
"lineHeight": "20px",
|
||||
"fontWeight": 500,
|
||||
"letterSpacing": "0.1px",
|
||||
"usage": "Buttons, TextButtons and chip labels.",
|
||||
"sample": "Search poems for “Hafez”"
|
||||
},
|
||||
{
|
||||
"name": "en-label-medium",
|
||||
"fontSize": "12px",
|
||||
"lineHeight": "16px",
|
||||
"fontWeight": 500,
|
||||
"letterSpacing": "0.5px",
|
||||
"usage": "Small badges and captions.",
|
||||
"sample": "Search poems for “Hafez”"
|
||||
},
|
||||
{
|
||||
"name": "en-label-small",
|
||||
"fontSize": "11px",
|
||||
"lineHeight": "16px",
|
||||
"fontWeight": 500,
|
||||
"letterSpacing": "0.5px",
|
||||
"usage": "The \"AI-generated by Ganjoor\" note above a summary, in primary.",
|
||||
"sample": "Search poems for “Hafez”"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"spacing": {
|
||||
"note": "Compose dp values as written in the screens; 1dp = 1px here.",
|
||||
"tokens": [
|
||||
{
|
||||
"name": "space-2",
|
||||
"value": "2px",
|
||||
"usage": "Hairline gaps: CircularProgressIndicator stroke width."
|
||||
},
|
||||
{
|
||||
"name": "space-4",
|
||||
"value": "4px",
|
||||
"usage": "Icon-to-text gap (pin mark beside a name), header bottom padding, summary top padding."
|
||||
},
|
||||
{
|
||||
"name": "space-6",
|
||||
"value": "6px",
|
||||
"usage": "Vertical padding around each couplet."
|
||||
},
|
||||
{
|
||||
"name": "space-8",
|
||||
"value": "8px",
|
||||
"usage": "The default gap: chip spacing, settings sheet rows, card internals, top list padding."
|
||||
},
|
||||
{
|
||||
"name": "space-12",
|
||||
"value": "12px",
|
||||
"usage": "Poet grid gutters and screen side padding on lists; poet card padding; settings toggle top margin."
|
||||
},
|
||||
{
|
||||
"name": "space-16",
|
||||
"value": "16px",
|
||||
"usage": "List bottom padding; empty-state padding; gap before the source line."
|
||||
},
|
||||
{
|
||||
"name": "space-20",
|
||||
"value": "20px",
|
||||
"usage": "Reader column side padding; settings sheet side padding."
|
||||
},
|
||||
{
|
||||
"name": "space-24",
|
||||
"value": "24px",
|
||||
"usage": "Space above the summary and the previous/next poem row."
|
||||
},
|
||||
{
|
||||
"name": "space-32",
|
||||
"value": "32px",
|
||||
"usage": "Bottom padding of the reader and the settings sheet."
|
||||
}
|
||||
]
|
||||
},
|
||||
"radius": {
|
||||
"note": "The app sets no shapes of its own; these are the Material 3 shape scale the components use.",
|
||||
"tokens": [
|
||||
{
|
||||
"name": "radius-xs",
|
||||
"value": "4px",
|
||||
"usage": "OutlinedTextField (Material extraSmall)."
|
||||
},
|
||||
{
|
||||
"name": "radius-sm",
|
||||
"value": "8px",
|
||||
"usage": "FilterChip (Material small)."
|
||||
},
|
||||
{
|
||||
"name": "radius-md",
|
||||
"value": "12px",
|
||||
"usage": "Card — poet cards in the grid (Material medium)."
|
||||
},
|
||||
{
|
||||
"name": "radius-lg",
|
||||
"value": "16px",
|
||||
"usage": "Material large; unused today."
|
||||
},
|
||||
{
|
||||
"name": "radius-xl",
|
||||
"value": "28px",
|
||||
"usage": "Top corners of ModalBottomSheet and AlertDialog (Material extraLarge)."
|
||||
},
|
||||
{
|
||||
"name": "radius-full",
|
||||
"value": "9999px",
|
||||
"usage": "Buttons, TextButtons, IconButton ripples, portrait discs (CircleShape)."
|
||||
}
|
||||
]
|
||||
},
|
||||
"size": {
|
||||
"note": "Fixed component sizes from the screens (dp = px).",
|
||||
"tokens": [
|
||||
{
|
||||
"name": "icon-pin",
|
||||
"value": "16px",
|
||||
"usage": "The pin mark beside a pinned poet."
|
||||
},
|
||||
{
|
||||
"name": "icon-inline",
|
||||
"value": "18px",
|
||||
"usage": "Share glyph inside a TextButton; download progress ring."
|
||||
},
|
||||
{
|
||||
"name": "icon-small",
|
||||
"value": "20px",
|
||||
"usage": "Couplet options chevron; small progress rings."
|
||||
},
|
||||
{
|
||||
"name": "icon",
|
||||
"value": "24px",
|
||||
"usage": "Default Material icon size in app bars and list rows."
|
||||
},
|
||||
{
|
||||
"name": "touch-compact",
|
||||
"value": "32px",
|
||||
"usage": "The couplet options IconButton."
|
||||
},
|
||||
{
|
||||
"name": "avatar-row",
|
||||
"value": "44px",
|
||||
"usage": "Poet portrait disc in list view."
|
||||
},
|
||||
{
|
||||
"name": "touch",
|
||||
"value": "48px",
|
||||
"usage": "IconButton and the non-interactive download tick box, so ticks and arrows line up."
|
||||
},
|
||||
{
|
||||
"name": "avatar-card",
|
||||
"value": "84px",
|
||||
"usage": "Poet portrait disc on a grid card."
|
||||
},
|
||||
{
|
||||
"name": "poet-grid-min",
|
||||
"value": "132px",
|
||||
"usage": "Minimum column width of the adaptive poet grid."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
BIN
design/wireframes/1-home.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
design/wireframes/2-poet-opened.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
design/wireframes/3-loading.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
design/wireframes/4-reading.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
design/wireframes/5-reader-view.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
design/wireframes/6-dictionary.png
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
design/wireframes/7-foldable.png
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
design/wireframes/8-settings.png
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
design/wireframes/9-phone-couplets.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
19
fastlane/metadata/android/en-US/changelogs/6.txt
Normal file
@ -0,0 +1,19 @@
|
||||
Tablets and unfolded foldables now read in columns: the poets on the right, then
|
||||
a column for each level of the open book, and the poem in whatever room is left.
|
||||
Each column scrolls on its own and narrows as you read, so the poem keeps the
|
||||
middle. A button in the top bar folds them away.
|
||||
|
||||
Where there is room, a couplet's two hemistichs sit on one line, as Ganjoor sets
|
||||
them on a desktop. Each couplet now sits in a soft card.
|
||||
|
||||
The dictionary and the reading settings open beside the poem rather than over
|
||||
it, so nothing you are reading is covered.
|
||||
|
||||
Pages fill in where they load instead of blanking the screen, and Home and the
|
||||
reading settings stay reachable while they do.
|
||||
|
||||
Tapping a word no longer carries the comma or question mark after it into the
|
||||
lookup.
|
||||
|
||||
A reading keeps playing when you scroll down the poem, and a seek bar moves it
|
||||
back and forth.
|
||||
10
fastlane/metadata/android/en-US/changelogs/7.txt
Normal file
@ -0,0 +1,10 @@
|
||||
The font weight control now works on every device.
|
||||
|
||||
Both bundled faces carry one variable axis, wght, and nothing else — but the app
|
||||
asked for two, sending an italic axis alongside it. A font with no italic axis
|
||||
may refuse the whole request rather than the part it cannot honour, and on a
|
||||
platform that does, every weight drew at Regular with nothing to show for it.
|
||||
Reported on Android 16.
|
||||
|
||||
On Android 7.0 and 7.1, where variable fonts do not exist at all, the heavier
|
||||
weights were unreachable for a related reason. They now work there too.
|
||||
17
fastlane/metadata/android/fa/changelogs/6.txt
Normal file
@ -0,0 +1,17 @@
|
||||
تبلتها و گوشیهای تاشوی باز حالا ستونی خوانده میشوند: شاعران در سمت راست، سپس
|
||||
یک ستون برای هر لایهٔ کتاب باز، و شعر در هر فضایی که میماند. هر ستون جداگانه
|
||||
پیمایش میشود و با خواندن باریکتر میشود تا شعر میانه را نگه دارد. دکمهای در
|
||||
نوار بالا آنها را جمع میکند.
|
||||
|
||||
هر جا جا باشد، دو مصرع یک بیت در یک سطر مینشینند، همانگونه که گنجور روی رایانه
|
||||
میچیند. هر بیت اکنون در کارتی ملایم مینشیند.
|
||||
|
||||
لغتنامه و تنظیمات خواندن کنار شعر باز میشوند، نه رویش، تا چیزی از آنچه
|
||||
میخوانید پوشیده نشود.
|
||||
|
||||
صفحهها همانجا که بار میشوند پر میشوند و صفحه سفید نمیماند؛ خانه و تنظیمات
|
||||
خواندن در همان حال هم در دسترساند.
|
||||
|
||||
ضربه بر یک واژه دیگر ویرگول یا علامت پرسش پس از آن را به جستوجو نمیبرد.
|
||||
|
||||
خوانش با پیمایش شعر قطع نمیشود، و نواری تازه آن را پس و پیش میبرد.
|
||||
9
fastlane/metadata/android/fa/changelogs/7.txt
Normal file
@ -0,0 +1,9 @@
|
||||
تنظیم ضخامت قلم اکنون روی همهٔ دستگاهها کار میکند.
|
||||
|
||||
هر دو قلم همراه برنامه تنها یک محور متغیر دارند، wght، و بس — اما برنامه دو
|
||||
محور میخواست و محور ایتالیک را هم میفرستاد. قلمی که محور ایتالیک ندارد میتواند
|
||||
کل درخواست را نپذیرد، نه فقط بخشی را که از عهدهاش برنمیآید؛ روی چنین سیستمی هر
|
||||
ضخامتی با همان حالت معمولی کشیده میشد. این مورد روی اندروید ۱۶ گزارش شد.
|
||||
|
||||
روی اندروید ۷٫۰ و ۷٫۱ که اصلاً قلم متغیر ندارند، ضخامتهای بیشتر به دلیلی
|
||||
نزدیک به همین در دسترس نبودند. آنجا هم درست شد.
|
||||
17
fastlane/metadata/android/ur/changelogs/6.txt
Normal file
@ -0,0 +1,17 @@
|
||||
ٹیبلٹ اور کھلے فولڈ ایبل اب ستونوں میں پڑھے جاتے ہیں: شعرا دائیں طرف، پھر کھلی
|
||||
کتاب کی ہر سطح کے لیے ایک ستون، اور باقی جگہ میں کلام۔ ہر ستون الگ سکرول ہوتا ہے
|
||||
اور پڑھتے وقت تنگ ہو جاتا ہے تاکہ کلام درمیان میں رہے۔ اوپر کی پٹی کا ایک بٹن
|
||||
انہیں سمیٹ دیتا ہے۔
|
||||
|
||||
جہاں گنجائش ہو، شعر کے دونوں مصرعے ایک ہی سطر میں آتے ہیں، جیسے گنجور کمپیوٹر پر
|
||||
دکھاتا ہے۔ ہر شعر اب ایک ہلکے کارڈ میں بیٹھتا ہے۔
|
||||
|
||||
لغت نامہ اور مطالعے کی ترتیبات کلام کے ساتھ کھلتی ہیں، اُس پر نہیں، تاکہ جو آپ
|
||||
پڑھ رہے ہیں وہ ڈھکا نہ جائے۔
|
||||
|
||||
صفحات وہیں بھرتے ہیں جہاں لوڈ ہوتے ہیں، خالی اسکرین نہیں آتی؛ ہوم اور مطالعے کی
|
||||
ترتیبات اُس دوران بھی دستیاب رہتی ہیں۔
|
||||
|
||||
کسی لفظ پر ٹیپ اب اُس کے بعد والا کوما یا سوالیہ نشان ساتھ نہیں لے جاتی۔
|
||||
|
||||
سکرول کرنے پر تلاوت بند نہیں ہوتی، اور ایک نئی سیک بار اُسے آگے پیچھے کرتی ہے۔
|
||||
9
fastlane/metadata/android/ur/changelogs/7.txt
Normal file
@ -0,0 +1,9 @@
|
||||
فونٹ کی موٹائی کا انتخاب اب ہر آلے پر کام کرتا ہے۔
|
||||
|
||||
ایپ کے ساتھ آنے والے دونوں فونٹس میں صرف ایک متغیر محور ہے، wght، اور بس — مگر
|
||||
ایپ دو مانگ رہی تھی اور ساتھ اٹالک محور بھی بھیج رہی تھی۔ جس فونٹ میں اٹالک محور
|
||||
نہ ہو وہ پورا مطالبہ رد کر سکتا ہے، نہ کہ صرف وہ حصہ جو وہ پورا نہیں کر سکتا؛ ایسے
|
||||
نظام پر ہر موٹائی عام ہی کی طرح بنتی رہی۔ یہ اینڈرائیڈ ۱۶ پر رپورٹ ہوا۔
|
||||
|
||||
اینڈرائیڈ ۷٫۰ اور ۷٫۱ پر، جہاں متغیر فونٹ ہوتے ہی نہیں، زیادہ موٹائیاں اسی سے
|
||||
ملتی جلتی وجہ سے ناقابلِ رسائی تھیں۔ وہاں بھی اب درست ہے۔
|
||||
35
metadata/com.ganjoor.android.yml
Normal file
@ -0,0 +1,35 @@
|
||||
# F-Droid metadata for Ganjoor for Android.
|
||||
#
|
||||
# This file is not read by the app or the build; it is the recipe F-Droid's build
|
||||
# server uses, kept here so it travels with the source it describes. To submit or
|
||||
# update the listing, copy it into a fork of gitlab.com/fdroid/fdroiddata as
|
||||
# metadata/com.ganjoor.android.yml and open a merge request.
|
||||
#
|
||||
# F-Droid builds from source and signs with their own key, which is why the
|
||||
# release build must succeed with no keystore present — see app/build.gradle.kts.
|
||||
|
||||
Categories:
|
||||
- Reading
|
||||
License: MIT
|
||||
AuthorName: Muhammad Anas Rashid
|
||||
SourceCode: https://github.com/anas-rashid/ganjoorandroid
|
||||
IssueTracker: https://github.com/anas-rashid/ganjoorandroid/issues
|
||||
Changelog: https://github.com/anas-rashid/ganjoorandroid/releases
|
||||
|
||||
AutoName: گنجور
|
||||
|
||||
RepoType: git
|
||||
Repo: https://github.com/anas-rashid/ganjoorandroid.git
|
||||
|
||||
Builds:
|
||||
- versionName: 0.4.0
|
||||
versionCode: 6
|
||||
commit: v0.4.0
|
||||
subdir: app
|
||||
gradle:
|
||||
- yes
|
||||
|
||||
AutoUpdateMode: Version
|
||||
UpdateCheckMode: Tags
|
||||
CurrentVersion: 0.4.0
|
||||
CurrentVersionCode: 6
|
||||
@ -12,7 +12,9 @@ from source and signs with its own key, so these are for direct installation onl
|
||||
| 0.2.0 | 2 | `d92445f` | Share a poem, couplet or selection; look a word up from any app's selection menu; the optional AI assistant. |
|
||||
| 0.2.1 | 3 | `ec05989` | Assistant answers in the page rather than over it, a visible chevron for each couplet's actions, and answers remembered so scrolling doesn't re-ask. |
|
||||
| 0.2.2 | 4 | `d7568d1` | Credits Material Symbols for the glyphs, and states the terms around the optional assistant. |
|
||||
| 0.3.0 | 5 | `HEAD` | Pin poets to the home screen: three views (pinned, Ganjoor's order, alphabetical), as cards or a list, with a download control on each row. |
|
||||
| 0.3.0 | 5 | `5d12a1c` | Pin poets to the home screen: three views (pinned, Ganjoor's order, alphabetical), as cards or a list, with a download control on each row. |
|
||||
| 0.4.0 | 6 | `270c28c` | Columns on tablets and unfolded foldables; the dictionary and reading settings beside the poem rather than over it; couplets in cards, two hemistichs to a line where there is room; skeletons while pages load; a seek bar on recitations. |
|
||||
| 0.4.1 | 7 | `HEAD` | The font weight control works on every device: the app was asking the bundled faces for an italic axis they do not have, and a font may refuse the whole request rather than the part it cannot honour. |
|
||||
|
||||
## Checksums
|
||||
|
||||
@ -22,6 +24,8 @@ from source and signs with its own key, so these are for direct installation onl
|
||||
5163554eb01845c131c31a9da7bdd748170bb9b5f82edaece754b4cf40b02b0c ganjoor-0.2.1.apk
|
||||
e473269044710813d2cf4119de663fe180f887239df7bdb1ccee0239ef84fd3e ganjoor-0.2.2.apk
|
||||
9c38d5cacf9e809c2189ba581f8aa6eb4072d4fa0316f00d66434461cf4b1459 ganjoor-0.3.0.apk
|
||||
1c64cefa0fb2b51bf73386460ec1b88bda3e20832fa6926c2f7f1fb863ab9fc7 ganjoor-0.4.0.apk
|
||||
6c85da41bf1416e34d6cb9c9eda9b4867e01b6793f21e476a6e3ece357615daa ganjoor-0.4.1.apk
|
||||
```
|
||||
|
||||
Installing: `adb install -r releases/ganjoor-<version>.apk`. A phone holding a *debug* build has
|
||||
|
||||
BIN
releases/ganjoor-0.4.0.apk
Normal file
BIN
releases/ganjoor-0.4.1.apk
Normal file
87
tools/update_release_docs.py
Executable file
@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Bring the download line in README.md and the checksum list in releases/README.md
|
||||
into step with whatever version app/build.gradle.kts declares.
|
||||
|
||||
Run it after building and copying the signed APK into releases/:
|
||||
|
||||
./gradlew :app:assembleRelease
|
||||
cp app/build/outputs/apk/release/ganjoor-<v>-release.apk releases/ganjoor-<v>.apk
|
||||
python3 tools/update_release_docs.py
|
||||
|
||||
It reads the version from the build file rather than taking an argument, so the
|
||||
documentation can only ever describe the release the build actually produces.
|
||||
ReleaseDocsTest fails if either file falls behind, so forgetting this is caught
|
||||
rather than shipped.
|
||||
"""
|
||||
import hashlib
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def version() -> str:
|
||||
build = (ROOT / "app/build.gradle.kts").read_text(encoding="utf-8")
|
||||
m = re.search(r'versionName\s*=\s*"([^"]+)"', build)
|
||||
if not m:
|
||||
sys.exit("could not find versionName in app/build.gradle.kts")
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
v = version()
|
||||
apk = ROOT / "releases" / f"ganjoor-{v}.apk"
|
||||
if not apk.exists():
|
||||
sys.exit(f"{apk.relative_to(ROOT)} is missing — build and copy it in first")
|
||||
|
||||
digest = hashlib.sha256(apk.read_bytes()).hexdigest()
|
||||
megabytes = apk.stat().st_size / 1_000_000
|
||||
|
||||
# README.md — the download heading and the line of facts under it.
|
||||
readme = ROOT / "README.md"
|
||||
text = readme.read_text(encoding="utf-8")
|
||||
text, n = re.subn(
|
||||
r"### ⬇ \[Download ganjoor-[^\]]+\]\(releases/ganjoor-[^)]+\)",
|
||||
f"### ⬇ [Download ganjoor-{v}.apk](releases/ganjoor-{v}.apk)",
|
||||
text,
|
||||
count=1,
|
||||
)
|
||||
if n != 1:
|
||||
sys.exit("could not find the download heading in README.md")
|
||||
short = f"{digest[:8]}…{digest[-11:]}"
|
||||
text, n = re.subn(
|
||||
r"Android 7\.0 and up · [^·]+· \[older releases\]\(releases/\) · `sha256 [^`]+`",
|
||||
f"Android 7.0 and up · {megabytes:.1f} MB · [older releases](releases/) · `sha256 {short}`",
|
||||
text,
|
||||
count=1,
|
||||
)
|
||||
if n != 1:
|
||||
sys.exit("could not find the download facts line in README.md")
|
||||
readme.write_text(text, encoding="utf-8")
|
||||
|
||||
# releases/README.md — one checksum line per archived APK, in version order.
|
||||
index = ROOT / "releases" / "README.md"
|
||||
listing = index.read_text(encoding="utf-8")
|
||||
line = f"{digest} ganjoor-{v}.apk"
|
||||
if line not in listing:
|
||||
# replace an existing line for this version, or append to the block
|
||||
existing = re.search(rf"^[0-9a-f]{{64}} ganjoor-{re.escape(v)}\.apk$", listing, re.M)
|
||||
if existing:
|
||||
listing = listing[: existing.start()] + line + listing[existing.end():]
|
||||
else:
|
||||
last = None
|
||||
for last in re.finditer(r"^[0-9a-f]{64} ganjoor-[^\n]+$", listing, re.M):
|
||||
pass
|
||||
if not last:
|
||||
sys.exit("could not find the checksum block in releases/README.md")
|
||||
listing = listing[: last.end()] + "\n" + line + listing[last.end():]
|
||||
index.write_text(listing, encoding="utf-8")
|
||||
|
||||
print(f"{v} {megabytes:.1f} MB {digest}")
|
||||
print("README.md and releases/README.md are in step")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||