Compare commits

..
Author SHA1 Message Date
anasandClaude Opus 5 41d776a35d Merge main, keeping only the part of this branch that is still needed
main has moved on since this branch opened, and most of what it carried has been
answered better there:

- the reading settings no longer need a fallback to the sheet, because they fold
  the columns away and take the room that frees (aab8b2e);
- the two panels no longer share a width, because the dictionary is now 216dp
  against the settings' 360dp.

Both were dropped: the conflicting files are taken from main as they stand.

What survives is the reason this branch exists. Which container the dictionary
uses is still decided by the width of the *window*, and that is the wrong
question — what matters is what is left of the page once the columns have taken
theirs. On a 700dp foldable that is about 450dp, and a panel beside it leaves
the verse a couple of characters a line. It now opens beside the poem only while
the page keeps 400dp, and falls back to the sheet below that, which covers the
foot of the poem but leaves every line whole.

Recalculated for the narrower panel: a tablet keeps the panel (~900dp page less
216dp leaves 684dp), a foldable in portrait does not (236dp).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-07 18:33:24 +02:00
anasandClaude Opus 5 bcdad2e699 Merge fix/one-side-panel-at-a-time
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-07 18:28:34 +02:00
anasandClaude Opus 5 ce26676d4e Merge fix/recitation-keeps-playing
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-07 18:28:34 +02:00
anasandClaude Opus 5 f60f9b4079 One side panel at a time, and a narrower one for the dictionary
Two fixes to the left of the reader.

The dictionary and the reading settings both want that side 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 now replace the dictionary; closing them leaves the poem, which is
where the reader was.

The dictionary also no longer takes the settings' full 360dp. It is read against
the line it came from, so the verse should keep the width: 216dp, a little over
half. The settings keep 360dp, which costs nothing — they fold the columns away
and take the room that frees.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-07 18:25:13 +02:00
anasandClaude Opus 5 488f48ad39 Keep the reading playing when the poem is scrolled, and let it be scrubbed
The player was an item of the LazyColumn, so scrolling it off screen disposed
it, and the DisposableEffect that releases the MediaPlayer cut the reading off
mid-line. It is now pinned above the scrolling text: it stays in composition for
as long as the poem is open, and stays in reach while you read further down,
which is when a player is wanted.

A seek bar comes with it — position, a draggable handle, and the two times —
shown only once the length is known, because a bar that cannot be dragged
anywhere is furniture. The handle follows the audio on a 250ms poll while it
plays and stops fighting the finger while it is being dragged. MediaPlayer
already seeks; this needed no media library.

The bar and its times run left to right inside the otherwise right-to-left page,
because time does, whatever the script.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-07 18:22:28 +02:00
Anas Rashid c3b22fdf0e Merge pull request #3 from anas-rashid/claude/design-system-extraction-c49naf
Fold the columns away while settings are open; couplets in rounded cards
2026-10-07 18:13:20 +02:00
anasandClaude Opus 5 936fd3cf3b Open the dictionary as a sheet where a panel would crush the poem
On a book-style foldable held open in portrait — 700dp, inside the 600–839dp
band — tapping a word made the poem unreadable. The panel is a fixed 360dp and
the columns keep their widths, so the page was left about 75dp: the verse broke
to one or two characters a line, and the breadcrumbs and the metre shattered the
same way.

The panel was chosen from the width of the window. That is the wrong question.
What matters is what is left of the page once the columns have taken their room,
so both panels now ask that instead:

- the dictionary opens beside the poem only while the page keeps 400dp;
- reading settings open beside the browser only while it keeps its own 600dp,
  below which the panel would squeeze the columns and the poem into less than
  the layout is built for.

Where they do not fit, the bottom sheet is used, exactly as on a phone: it
covers the foot of the poem but leaves every line whole. A tablet is unaffected
— verified at 2560x1600 that the panel still opens and the poem still reads.

The two panels now share one width, and the design notes record the rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-07 18:04:39 +02:00
Claude 64a67ddbbd Set each couplet in a dim rounded card, on every screen size
Each line of verse now sits in its own soft card (surface-container-high,
12dp corners), so the eye finds where one couplet ends and the next
begins, and the couplet's chevron, actions and summary visibly belong to
it. On OLED black that step is all but black, so the card takes the next
one up there. Prose stays bare: a paragraph in a box reads as a quotation.

Text keeps at least 6:1 on the card in every theme. The phone layout is
otherwise unchanged.

Updates the Couplet notes, the stylesheet and wireframes 4-8, and adds
wireframe 9 for the phone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RhmDdrN5hgWrgRcFLAMsV6
2026-10-07 16:04:08 +00:00
Claude aab8b2e3e1 Fold the columns away while reading settings are open
On a large screen the settings panel opens on the left while the poets
and book columns sit on the right, which left the page squeezed between
them. The columns now step aside while the panel is open and come back
when it closes, but only if they were showing: this is a transient flag
(LocalSidePanelOpen), never the saved reader-view choice. The floating
show-the-list button stays hidden while the panel is open too.

Updates wireframe 8 and the ColumnBrowser notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RhmDdrN5hgWrgRcFLAMsV6
2026-10-07 16:00:55 +00:00
anasandClaude Opus 5 f21f4fa727 Keep home and the reading settings up while a page loads
The skeleton screens carried a top bar with nothing but Back, so going home or
changing the theme meant waiting for a poem you might not have wanted. Neither
action depends on the content: both are now in the loading bar for the poem and
the category alike, from the first frame.

Share and bookmark are not. They need a poem to act on, so they still arrive
with it — the bar fills out as the content lands rather than appearing whole.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-07 17:08:19 +02:00
anasandClaude Opus 5 b3f4e527f1 Look up the word, not the word and the comma after it
A tapped word was taken as the longest run of characters in U+0600–U+06FF, but
that block is not only letters. The Arabic comma ، semicolon ؛ question mark ؟
full stop ۔ and both sets of Indic digits all live inside it, so the run ran
straight through them: tapping دستم in «ز دستم، صاحب‌دلان» looked up «دستم،»,
which no dictionary carries and which the near-word search cannot rescue either,
since the punctuation counts against every candidate's letter overlap.

The run now keeps only letters, the marks that sit on them, and the joiner,
decided by Unicode category rather than a hand-kept list of code points. Harakat
and tatweel stay in: they sit inside a word — منِ is one word — and normalise()
strips them before the lookup. Latin punctuation already fell outside the block.

The range is also what the verse highlights, so the comma is no longer painted
as part of the tapped word.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-07 17:08:19 +02:00
Anas Rashid 1a1c86ee64 Merge pull request #2 from anas-rashid/claude/design-system-extraction-c49naf
Open reading settings in the left-hand panel on large screens
2026-10-07 16:57:43 +02:00
Claude af330d081f Merge remote-tracking branch 'origin/main' into claude/design-system-extraction-c49naf 2026-10-07 14:57:04 +00:00
anasandClaude Opus 5 a118fc2510 Fix the build: AnimatedVisibility resolved to the RowScope overload
main did not compile. The floating button that brings the columns back sits in
a Box, but that Box is inside the browser's Row, so RowScope was still an
implicit receiver where AnimatedVisibility was called. Kotlin picked
RowScope.AnimatedVisibility — which has no alignment parameter and cannot take
an implicit receiver there — and the Kotlin compile failed.

Moving it into its own composable gives it a scope of its own, so the plain
overload resolves and Modifier.align stays with the Box that provides it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-07 16:54:59 +02:00
Claude 8451cc45fb Open reading settings in the left-hand panel on large screens
On a tablet or an unfolded foldable the gear now opens reading settings
as a panel on the left, where the dictionary opens, instead of a bottom
sheet. The page stays in view beside it, so a change of theme, font,
weight or size shows on the poem as it is made. The sheet and the panel
share one ReadingSettingsContent; phones keep the sheet.

Adds wireframe 8 and notes in the design docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RhmDdrN5hgWrgRcFLAMsV6
2026-10-07 14:51:12 +00:00
Anas Rashid f0c1dd5541 Merge pull request #1 from anas-rashid/claude/design-system-extraction-c49naf
Design system, and a column layout for tablets and foldables
2026-10-07 16:10:53 +02:00
Claude ebbf737dac Add the design system extracted from the app
design/ holds the tokens for all six themes, the type scale, spacing,
radii and sizes, the brand and usage guidelines, a guide and static
preview for each component, the logo and icons as SVG, and wireframes of
the tablet and foldable column layout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RhmDdrN5hgWrgRcFLAMsV6
2026-10-07 14:06:52 +00:00
Claude cf65072af4 Add a column layout for tablets and unfolded foldables
At 600dp and wider, opening a poet turns the home grid into a narrow
column of poets on the right, with a column for each level of the open
book (books, chapters, poems) beside it and the page in the rest of the
screen. Columns narrow as more open and while a poem is being read, and
can be hidden for a reader view, with a floating button to bring them
back. Below 840dp only the newest list column is shown. The routes are
the same as on a phone, so folding or unfolding keeps your place.

On large screens the dictionary opens in a panel to the left of the poem
rather than as a bottom sheet, and the looked-up word stays highlighted.

Loading no longer covers the whole screen with a spinner, on phones too:
only the part that is waiting shows a skeleton shaped like its content,
under the real top bar, and fades into the content when it arrives.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RhmDdrN5hgWrgRcFLAMsV6
2026-10-07 14:05:07 +00:00
anasandClaude Opus 5 50bbe0b729 Correct the unofficial statement: separate, not unendorsed
The first wording claimed this app is "not affiliated with, endorsed by" the
owners of ganjoor.net and that they "bear no responsibility for it". That
overstates the distance: permission to build it was given. What is true, and
all that needed saying, is that this is a separate project and they do not run
or manage it. The denial of endorsement is gone from all three languages and
from both places in the README.

The author is named in full — Muhammad Anas Rashid — everywhere it appears.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-07 15:12:11 +02:00
anasandClaude Opus 5 5d12a1c29c Merge 0.3.0: pinned poets, list view, and the unofficial-client statement
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-07 14:59:02 +02:00
anasandClaude Opus 5 16bcd6949f Say plainly that this is an unofficial client, and name what it is built on
Someone arriving from ganjoor.net had to infer from the credits that this is a
separate project. The About page now opens with it, before the licence list,
in all three interface languages: independent, unofficial, maintained by Anas
Rashid, not affiliated with or endorsed by the owners of ganjoor.net, who carry
no responsibility for it. The README leads with the same paragraph and points
issues here rather than at the Ganjoor project.

The README also gains a full inventory of the open source this app is built on
— Ganjoor and GanjoorService, ganjoor-data, Daneshjoo, Wiktionary and Urdu
Wiktionary, wiktextract, the three fonts, and every library — each with its
repository and terms. licenses/README.md already carried most of it, but a
reader looking for "which repos does this use" should not have to open a second
file to find out.

GanjoorService is listed with the point that matters: GPL-3.0, no code used,
only data over HTTPS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-07 14:58:28 +02:00
Anas RashidandClaude Opus 5 b4a8efe9d4 Refresh the 0.3.0 checksum after a clean rebuild
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 20:39:45 +02:00
Anas RashidandClaude Opus 5 929aee2a02 Download a poet from the list, and show when they are already here
A download control on each row in the list view: the arrow starts it, a spinner
replaces it while it runs and cancels on a tap, and a green tick says the poems
are already on the device. Only text is fetched — recitations stream when
played, and nothing about that changes.

The tick sits in a box the size of an IconButton although nothing about it is
tappable. A bare icon lands where the button's padding would have put it, so
the ticks and the arrows did not line up down the column.

The green is derived from the scheme's own luminance rather than fixed:
Material has no success colour, and one green goes muddy on sepia and glares on
OLED black.

Deleting stays on the downloads page, where the sizes are. A tap beside a
poet's name should not be the thing that throws their poems away.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 20:36:59 +02:00
Anas RashidandClaude Opus 5 a5cc0421b6 Archive 0.3.0
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 20:28:12 +02:00
Anas RashidandClaude Opus 5 c9269713d1 Pin poets to the home screen
Three views, chosen by the chips: the poets you pinned, Ganjoor's own order,
and alphabetical. Press and hold a poet anywhere to pin or unpin — tapping
still opens them, and a pin marker sits beside the name.

Pinned is the default. With nothing pinned it is Ganjoor's order untouched, so
a fresh install never opens to an empty screen; the hint about holding a poet
shows only while the shelf is empty, because once there is something on it the
shelf explains itself.

Pins keep the order they were made in rather than being sorted. A shelf someone
arranges themselves should stay where they put it, and a new pin appearing in
the middle of the row is disorienting.

Cards or a list, switched from the app bar and remembered. The same storage as
bookmarks: a list of a few urls needs no schema.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 20:26:26 +02:00
Anas RashidandClaude Opus 5 d7568d1f49 Credit Material Symbols, and state the assistant's terms
Two gaps in the licence file, both from work done after it was written.

The share, lookup and assistant glyphs are Material Symbols path data copied
into our own vector files. The Libraries table credits the Material icons
library, which is not the same thing as copying its paths, so the drawables now
have their own entry and appear in the app's About page.

The assistant was not mentioned at all. It ships no vendor SDK, no key and no
default endpoint, so nothing non-free is distributed — but a reader may point
it at a service whose terms are their own, and what a model writes back belongs
to neither Ganjoor nor this app. Written down for F-Droid's sake as much as the
reader's.

No new dependencies came with any of it: the assistant reuses the HTTP client
and JSON parser already here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 20:19:00 +02:00
Anas RashidandClaude Opus 5 e3cad61e9e Keep every release build
Three so far, each signed with the same key so any of them upgrades any other
without losing bookmarks: 0.1.0 as it stood last night, 0.2.0 with share and
the assistant, and 0.2.1 with answers in the page.

These are binaries in git history, which is permanent and makes every clone
carry them. Gitea's own releases attach a build to a tag without that cost;
keeping them here is deliberate, so a version can be reinstalled without
finding the commit and rebuilding it. Tags v0.1.0, v0.2.0 and v0.2.1 mark the
commits each was built from, so they stay reproducible either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 20:12:04 +02:00
Anas RashidandClaude Opus 5 ec05989f7d Release 0.2.1
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 19:51:20 +02:00
Anas RashidandClaude Opus 5 5daebcd87a Show the assistant's answers in the page, and make a couplet's options findable
Translations appear under the summary they translate. Leaving the poem to read
one — to a sheet over the top, or to another app entirely — breaks the reading,
which is the thing the feature is meant to help with. Ganjoor prints a summary
under each couplet as well as under the poem, so both now carry a translate
button, the couplet one only once a server is configured: a button under every
couplet earns its space only if it can answer.

A couplet's own actions were effectively unreachable. They opened on a tap that
landed between words, and on a full line of poetry a tap almost always lands on
a word, which opens the dictionary instead — three attempts here failed before
one worked. There is now a chevron at the end of every couplet, and the same
actions sit at the foot of the word sheet, inside its scroll rather than after
it, where they were pushed past the bottom of the sheet with no way to reach
them.

Answers are remembered. A LazyColumn disposes what scrolls out of view, which
restarted the request behind it: one tap on translate sent two, confirmed
against a stub server, and scrolling away and back would have sent more. On a
metered API that is money.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 19:49:15 +02:00
Anas RashidandClaude Opus 5 d92445f21e Release 0.2.0
Pre-1.0 while the app is still being shaped: 0.1.0 was the build after the
dictionary work, this is the one with share and the optional assistant.

The APK is named for its version, because a file called app-release.apk tells
whoever receives it nothing. Changelogs in all three languages, one per
versionCode, which is the layout F-Droid reads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 19:21:53 +02:00
anas 2f9cb0e211 Merge pull request 'feature/ai-assistant' (#2) from feature/ai-assistant into main
Reviewed-on: #2
2026-10-04 17:16:53 +00:00
Anas RashidandClaude Opus 5 d320137063 Drop the "ask an assistant" entry from the selection menu
With no local server configured it opened the same chooser Share does, only
with a prompt in front of the text. Two entries for one chooser makes the
reader choose twice, and the third entry pushed Look up into the overflow;
both now fit in the bar.

Asking moves to the lookup screen's top bar, where it keeps the part that was
actually distinct: a configured server answers in place, and without one the
question still goes out with "translate this and explain what it means"
already written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 19:14:22 +02:00
Anas RashidandClaude Opus 5 90b527522b Use the AI sparkle for the assistant action
Material's auto_awesome, the shape that has come to mean "ask a model" — the
same one Gemini and the rest use. Theirs are trademarks and have no place in an
F-Droid build; this one ships under the Apache licence already covering the
app's icons.

It now marks the action everywhere it appears: the selection-menu entry, the
lookup screen's top bar, and the translate and explain buttons in the reader,
which were text alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 19:02:27 +02:00
Anas RashidandClaude Opus 5 2f16b52c2c Use the standard glyphs for share, look up and ask
Share was already the Material share icon in both top bars, but appeared as
bare text in the couplet actions and under an assistant's answer, so the same
action looked like two different things. It now carries the glyph everywhere.

The selection-menu entries had no icons of their own, so the system drew the
app logo three times over. Each alias now has the icon for its verb, from
Material's own path data: the share glyph people know from every other app,
a magnifier for the dictionary, and send for handing a question elsewhere.
Whether the floating toolbar draws them is the system's decision, not ours.

"Ask an assistant" had a magnifier, which belongs to searching. It sends.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 19:00:09 +02:00
Anas RashidandClaude Opus 5 65bc9ed0fc Talk to Claude as well as the OpenAI-shaped services
Everything else worth connecting to — Ollama, LM Studio, llama.cpp, LocalAI,
OpenAI, DeepSeek, and Gemini through its compatibility endpoint — speaks the
OpenAI chat-completions shape, so one client covered them all. Anthropic does
not: it is /v1/messages, an x-api-key header, and a reply that arrives as
content blocks rather than a message. That one exception is now handled, keyed
off the host, so the callers never see the difference.

The endpoint note lists the services by name and URL. Knowing the app "supports
OpenAI-compatible servers" is not the same as knowing what to type.

Presets stay self-hosted only: naming a paid service in a one-tap button is
steering people towards it, and the field takes any URL regardless.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 18:54:19 +02:00
Anas RashidandClaude Opus 5 576a07ae31 Wire up the optional AI assistant
The page existed but nothing reached it. It now has a route, a provider in
MainActivity, and an entry at the foot of the reading-settings sheet beside
About, where it reads as one more optional setting rather than a feature of
the app.

Two actions use it: translating Ganjoor's own Persian summary under a poem,
and explaining a selected couplet. Both go through AssistantAction, which asks
the reader's own server when one is configured and otherwise hands the question
to another installed app. Neither path needs anyone to have set anything up,
which matters because the whole feature is optional.

Urdu for AI is مصنوعی ذہانت, not the transliterated "اے آئی"; the Farsi now
says هوش مصنوعی to match, rather than "smart assistant".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 18:49:37 +02:00
Anas RashidandClaude Opus 5 3d4f7aae3a Share verses and poems, and reach the dictionary from any app
Selected text now gets three entries in the system selection menu — look up,
ask an assistant, and share — and the poem screen gains a share action of its
own, next to the per-couplet save and copy.

Compose 1.10 stopped routing SelectionContainer through LocalTextToolbar, so a
custom entry cannot be added to that menu from inside the reader.
ACTION_PROCESS_TEXT goes round it: the system builds part of the menu from
installed activities handling that intent, so these appear here and in every
other app. Three activity-aliases rather than one Ganjoor entry, because the
menu is a place for verbs: a reader who means "look this up" should not have to
pick an app first and then say what they wanted.

"Ask an assistant" cannot name Claude or Gemini — only those apps can put their
own name in that menu. It opens the share chooser with the question already
written, which lists whichever assistant is installed. That hand-off is also
what keeps this acceptable under F-Droid's rules: no vendor SDK, no API key,
nothing but an intent the reader confirms.

The activity is opaque and themed rather than a transparent one hosting a
sheet. PROCESS_TEXT starts it in its own task, so the app the text came from is
not behind this window, and a scrim over nothing is just a grey screen.

WordLookup and AssistantAnswer are split out of their sheets so the same code
serves as a sheet in the reader and a screen here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 18:45:13 +02:00
anas 9445f44759 Merge pull request 'Dictionary coverage, pronunciation, and chapter order' (#1) from feature/dictionary-coverage-and-chapter-order into main
Reviewed-on: #1
2026-10-04 16:12:01 +00:00
114 changed files with 4994 additions and 177 deletions

No files matched your search

+71 -1
View File
@@ -2,6 +2,13 @@
<div dir="rtl">گنجور — خوانندهٔ شعر پارسی برای اندروید</div>
> **Unofficial.** This is an independent, unofficial Android client for
> [ganjoor.net](https://ganjoor.net). It is a **separate project** from ganjoor.net and its
> GitHub repositories ([`ganjoor/ganjoor`](https://github.com/ganjoor),
> `GanjoorService`, and the rest), built and maintained by **Muhammad Anas Rashid**. The owners of
> ganjoor.net do not run or manage it. Please direct any issue with this app
> [here](https://github.com/anas-rashid/ganjoorandroid/issues) — never to the Ganjoor project.
An Android reader for [Ganjoor](https://ganjoor.net), the open archive of Persian poetry —
240 poets and ~135,000 poems, laid out for comfortable long-form reading in Persian, Urdu
and Arabic script, online or fully offline.
@@ -21,6 +28,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 |
@@ -164,6 +176,61 @@ guide](https://f-droid.org/en/docs/Submitting_to_F-Droid_Quick_Start_Guide/):
Two things to do before submitting: tag a release (`v0.1.0`), and check that F-Droid's build
server supports **AGP 9.4.1** — it is new, and that is the most likely thing to hold up a merge.
## Open source this app is built on
Every third-party project this app uses, with its repository and terms. None of these projects
endorse or maintain this app. Full licence texts are in [`licenses/`](licenses/) and ship inside
the APK.
### Content and data
| Project | Repository / source | Terms | How it is used |
|---|---|---|---|
| Ganjoor | [ganjoor.net](https://ganjoor.net) | Classical Persian verse, long out of copyright | The poems themselves |
| GanjoorService | [ganjoor/GanjoorService](https://github.com/ganjoor/GanjoorService) | GPL-3.0 | **No code used.** Only `api.ganjoor.net` over HTTPS, for poem search, opening lines and poet portraits — so this app is not a derivative work |
| ganjoor-data | [anas-rashid/ganjoor-data](https://github.com/anas-rashid/ganjoor-data) | **No licence file stated** | The static JSON export every poem is read from |
### Dictionary
| Project | Repository / source | Terms | How it is used |
|---|---|---|---|
| Daneshjoo Dictionary | [0xdolan/Daneshjoo](https://github.com/0xdolan/Daneshjoo) | Repository states MIT (see caveat below) | Persian → English definitions |
| Wiktionary | [en.wiktionary.org](https://en.wiktionary.org) | CC BY-SA 3.0 | Persian/Urdu/Arabic → English definitions, IPA, form→lemma index |
| Urdu Wiktionary | [ur.wiktionary.org](https://ur.wiktionary.org) | CC BY-SA 3.0 | The only Urdu → Urdu definitions, plus vowelled spelling and syllable split |
| wiktextract / kaikki.org | [tatuylonen/wiktextract](https://github.com/tatuylonen/wiktextract) | See repository | Produces the machine-readable Wiktionary exports the build consumes |
| readmdict | [readmdict](https://pypi.org/project/readmdict/) | See project | Build-time only — reads Daneshjoo's `.mdx` |
Because four of the five dictionary sources are CC BY-SA 3.0, **the generated `dictionary.db` is
CC BY-SA 3.0**. The Daneshjoo caveat: the repository states MIT, but the underlying lexicon is a
published Iranian dictionary, so that relicensing is worth verifying before relying on it.
### Fonts
| Project | Repository | Terms |
|---|---|---|
| Noto Naskh Arabic | [notofonts/arabic](https://github.com/notofonts/arabic) | SIL OFL 1.1 |
| Noto Nastaliq Urdu | [notofonts/nastaliq](https://github.com/notofonts/nastaliq) | SIL OFL 1.1 |
| Libron | [nicoverbruggen/libron](https://github.com/nicoverbruggen/libron) | SIL OFL 1.1 |
### Libraries and icons
All Apache-2.0.
| Project | Repository |
|---|---|
| Jetpack Compose, AndroidX (core, activity, lifecycle, navigation) | [androidx/androidx](https://github.com/androidx/androidx) |
| Kotlin standard library | [JetBrains/kotlin](https://github.com/JetBrains/kotlin) |
| kotlinx.serialization | [Kotlin/kotlinx.serialization](https://github.com/Kotlin/kotlinx.serialization) |
| kotlinx.coroutines | [Kotlin/kotlinx.coroutines](https://github.com/Kotlin/kotlinx.coroutines) |
| OkHttp | [square/okhttp](https://github.com/square/okhttp) |
| Okio | [square/okio](https://github.com/square/okio) |
| Coil 3 | [coil-kt/coil](https://github.com/coil-kt/coil) |
| Accompanist (drawablepainter, via Coil) | [google/accompanist](https://github.com/google/accompanist) |
| Material Symbols (share, lookup, assistant glyphs, copied as vector paths) | [google/material-design-icons](https://github.com/google/material-design-icons) |
No Google Play Services, Firebase, analytics or trackers. The only permission requested is
`INTERNET`.
## Licensing
The app code is MIT (see [`LICENSE`](LICENSE)). Every other component — the poems, the three
@@ -175,4 +242,7 @@ The two things worth knowing up front: the **data set** carries no licence file,
**GanjoorService** is GPL-3.0 but none of its code is used here — only data over HTTPS — so this
app is not a derivative work of it.
Not affiliated with or endorsed by Ganjoor.
This app is an **unofficial, independent** client, built and maintained by **Muhammad Anas Rashid**. It is
a wholly separate project from ganjoor.net and the Ganjoor GitHub repositories, and the owners of
ganjoor.net do not run or manage it. The same statement is shown in the app itself, at **Reading
settings → About & licences**.
+10 -2
View File
@@ -14,6 +14,11 @@ val keystoreProperties = Properties().apply {
if (file.exists()) file.inputStream().use { load(it) }
}
// A delivered file should say what it is without being opened.
base {
archivesName = "ganjoor-0.3.0"
}
android {
namespace = "com.ganjoor.android"
compileSdk {
@@ -24,8 +29,11 @@ android {
applicationId = "com.ganjoor.android"
minSdk = 24
targetSdk = 37
versionCode = 1
versionName = "0.1.0"
// 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"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
+42 -1
View File
@@ -12,7 +12,7 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:usesCleartextTraffic="false"
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@style/Theme.Ganjoorappandroid">
<activity
android:name=".MainActivity"
@@ -26,6 +26,47 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!--
The selection menu entries, one per action. Android builds part of that menu from
installed ACTION_PROCESS_TEXT activities, so these appear in this app and in every other
one. Three aliases rather than a single Ganjoor entry: the menu is a place for verbs, and
a reader who means "look this up" should not have to pick the app first.
There is no "ask an assistant" entry: with no local server configured it would open the
same chooser Share does, and two entries for one chooser makes the reader choose twice.
Asking lives on the lookup screen instead, where it can use a configured server.
-->
<activity
android:name=".ProcessTextActivity"
android:exported="false"
android:theme="@style/Theme.Ganjoorappandroid" />
<activity-alias
android:name=".LookUpText"
android:icon="@drawable/ic_lookup"
android:targetActivity=".ProcessTextActivity"
android:exported="true"
android:label="@string/look_up">
<intent-filter>
<action android:name="android.intent.action.PROCESS_TEXT" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity-alias>
<activity-alias
android:name=".ShareText"
android:icon="@drawable/ic_share"
android:targetActivity=".ProcessTextActivity"
android:exported="true"
android:label="@string/share">
<intent-filter>
<action android:name="android.intent.action.PROCESS_TEXT" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity-alias>
</application>
</manifest>
@@ -16,6 +16,10 @@ import com.ganjoor.android.data.Dictionary
import com.ganjoor.android.data.Ganjoor
import com.ganjoor.android.data.LocalBookmarks
import com.ganjoor.android.ui.GanjoorApp
import com.ganjoor.android.data.AssistantSettings
import com.ganjoor.android.data.LocalAssistant
import com.ganjoor.android.data.LocalPinnedPoets
import com.ganjoor.android.data.PinnedPoets
import com.ganjoor.android.ui.LocalSettings
import com.ganjoor.android.ui.Settings
import com.ganjoor.android.ui.theme.GanjoorTheme
@@ -53,11 +57,15 @@ class MainActivity : ComponentActivity() {
setContent {
val settings = remember { settings }
val bookmarks = remember { Bookmarks(applicationContext) }
val assistant = remember { AssistantSettings(applicationContext) }
val pinned = remember { PinnedPoets(applicationContext) }
// The client reads this flag on every request, so keep it in step with the setting.
Ganjoor.offline = settings.value.offline
CompositionLocalProvider(
LocalSettings provides settings,
LocalBookmarks provides bookmarks,
LocalAssistant provides assistant,
LocalPinnedPoets provides pinned,
// The poetry is Persian, Urdu and Arabic throughout, so the whole app reads and
// navigates right-to-left whichever UI language is selected.
LocalLayoutDirection provides LayoutDirection.Rtl,
@@ -0,0 +1,180 @@
package com.ganjoor.android
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.core.graphics.drawable.toDrawable
import com.ganjoor.android.data.AssistantSettings
import com.ganjoor.android.data.Dictionary
import com.ganjoor.android.data.LocalAssistant
import com.ganjoor.android.ui.AssistantResultSheet
import com.ganjoor.android.ui.LocalSettings
import com.ganjoor.android.ui.Settings
import com.ganjoor.android.ui.WordLookup
import com.ganjoor.android.ui.theme.readingStyle
import com.ganjoor.android.ui.shareText
import com.ganjoor.android.ui.theme.GanjoorTheme
import com.ganjoor.android.ui.theme.windowBackground
import java.util.Locale
/**
* What the app offers on selected text, anywhere on the phone.
*
* One activity behind three aliases — LookUpText, AskAssistantText and ShareText — so the system
* menu shows verbs rather than one Ganjoor entry that then asks what you meant. Which alias was
* tapped arrives as the intent's component name.
*
* Compose stopped routing SelectionContainer through LocalTextToolbar, so an entry can't be added
* to that menu from inside the reader. ACTION_PROCESS_TEXT goes round that: the system builds part
* of the selection menu from activities that handle this intent. Registering one puts Ganjoor
* there — and not only in this app. Select a Persian word in a browser or a messaging app and the
* dictionary is one tap away.
*
* It is an ordinary opaque screen, not a transparent one with a sheet. The intent starts it in its
* own task, so the app the text was selected in is not behind this window; a translucent activity
* showed a grey void with the dim scrim over nothing.
*/
class ProcessTextActivity : ComponentActivity() {
override fun attachBaseContext(newBase: Context) {
val config = Configuration(newBase.resources.configuration)
config.setLocale(Locale.forLanguageTag(Settings.language(newBase).tag))
super.attachBaseContext(newBase.createConfigurationContext(config))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Dictionary.init(applicationContext)
val selected = (
intent.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT)
?: intent.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT_READONLY)
)?.toString()?.trim().orEmpty()
if (selected.isEmpty()) {
finish()
return
}
val sharing = intent.component?.className?.endsWith("ShareText") == true
val settings = Settings(applicationContext)
val systemInDark = resources.configuration.uiMode and
Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
// Painted before the first frame, so this doesn't flash white on the way in.
window.setBackgroundDrawable(
windowBackground(settings.value.theme, systemInDark, settings.value.oled).toDrawable()
)
setContent {
val assistant = remember { AssistantSettings(applicationContext) }
CompositionLocalProvider(
LocalSettings provides settings,
LocalAssistant provides assistant,
LocalLayoutDirection provides LayoutDirection.Rtl,
) {
GanjoorTheme(settings.value.theme, settings.value.language, settings.value.oled) {
SelectionScreen(selected, sharing, onClose = ::finish)
}
}
}
}
}
/**
* Share goes straight to the system chooser and finishes, so this screen is only ever seen for a
* lookup. Asking an assistant is a button here rather than an entry of its own in the selection
* menu: with no server configured it opens the same chooser Share does, and two menu entries for
* one chooser is a menu that makes the reader choose twice.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SelectionScreen(selected: String, sharing: Boolean, onClose: () -> Unit) {
val context = LocalContext.current
val assistant = LocalAssistant.current
val prompt = stringResource(R.string.assistant_ask_prompt)
var asking by remember { mutableStateOf(false) }
LaunchedEffect(sharing) {
if (sharing) {
context.shareText(selected)
onClose()
}
}
if (sharing) return
Scaffold(
topBar = {
TopAppBar(
title = { Text(selected, maxLines = 1, overflow = TextOverflow.Ellipsis) },
navigationIcon = {
IconButton(onClick = onClose) {
Icon(Icons.Default.Close, stringResource(R.string.back))
}
},
actions = {
// The dictionary knows single words; a whole line is better asked about. Your
// own server answers here; without one the question goes to whichever
// assistant is installed, which is what keeps this F-Droid-clean — no vendor
// SDK, no key, nothing but an intent you confirm.
IconButton(onClick = {
if (assistant.serverReady) asking = true
else context.shareText("$prompt\n\n$selected")
}) {
Icon(
painter = painterResource(R.drawable.ic_ask),
contentDescription = stringResource(R.string.assistant_ask),
)
}
IconButton(onClick = { context.shareText(selected) }) {
Icon(Icons.Default.Share, stringResource(R.string.share))
}
},
)
},
) { insets ->
Column(modifier = Modifier.fillMaxSize().padding(insets)) {
WordLookup(selected)
}
}
if (asking) {
AssistantResultSheet(
prompt = "explain",
text = selected,
onDismiss = { asking = false },
)
}
}
@@ -0,0 +1,279 @@
package com.ganjoor.android.data
import android.content.Context
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.core.content.edit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException
import java.util.concurrent.TimeUnit
/**
* How the app reaches a language model, if the reader wants one at all.
*
* Nothing here is required: every feature of the app works with this switched off, which is both
* the point and what keeps it acceptable as free software — no account, no bundled vendor SDK,
* no key shipped with the app, and no default endpoint.
*/
enum class AssistantMode { Off, Server, ShareToApp }
/** The language a translation or summary is asked for. */
enum class AssistantLanguage(val code: String, val englishName: String) {
Urdu("ur", "Urdu"),
English("en", "English"),
}
/**
* A server the reader runs or subscribes to. Only the free, self-hosted ones are offered as
* presets; anything else is typed in, so the app never steers anyone towards a paid service.
*/
data class AssistantPreset(val name: String, val baseUrl: String, val model: String)
val ASSISTANT_PRESETS = listOf(
AssistantPreset("Ollama", "http://127.0.0.1:11434/v1", "llama3.2"),
AssistantPreset("LM Studio", "http://127.0.0.1:1234/v1", "local-model"),
AssistantPreset("llama.cpp", "http://127.0.0.1:8080/v1", "local-model"),
AssistantPreset("LocalAI", "http://127.0.0.1:8080/v1", "local-model"),
)
/** Reader-supplied configuration, kept on the device and never sent anywhere but the endpoint. */
class AssistantSettings(context: Context) {
private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
var mode by mutableStateOf(
runCatching { AssistantMode.valueOf(prefs.getString(MODE, null)!!) }
.getOrDefault(AssistantMode.Off)
)
private set
var baseUrl by mutableStateOf(prefs.getString(BASE_URL, "").orEmpty())
private set
var model by mutableStateOf(prefs.getString(MODEL, "").orEmpty())
private set
/**
* Optional, and only needed by services that demand one — a server on your own machine
* usually doesn't. Stored in the app's private preferences, unencrypted, like any other
* setting; don't put a key here you would mind someone with your unlocked phone reading.
*/
var apiKey by mutableStateOf(prefs.getString(API_KEY, "").orEmpty())
private set
var language by mutableStateOf(
runCatching { AssistantLanguage.valueOf(prefs.getString(LANGUAGE, null)!!) }
.getOrDefault(AssistantLanguage.Urdu)
)
private set
fun update(
mode: AssistantMode = this.mode,
baseUrl: String = this.baseUrl,
model: String = this.model,
apiKey: String = this.apiKey,
language: AssistantLanguage = this.language,
) {
this.mode = mode
this.baseUrl = baseUrl.trim()
this.model = model.trim()
this.apiKey = apiKey.trim()
this.language = language
prefs.edit(commit = true) {
putString(MODE, mode.name)
putString(BASE_URL, this@AssistantSettings.baseUrl)
putString(MODEL, this@AssistantSettings.model)
putString(API_KEY, this@AssistantSettings.apiKey)
putString(LANGUAGE, language.name)
}
}
/** Whether a request can actually be made, as opposed to merely being switched on. */
val serverReady: Boolean
get() = mode == AssistantMode.Server && baseUrl.isNotBlank() && model.isNotBlank()
private companion object {
const val PREFS = "ganjoor"
const val MODE = "assistantMode"
const val BASE_URL = "assistantBaseUrl"
const val MODEL = "assistantModel"
const val API_KEY = "assistantApiKey"
const val LANGUAGE = "assistantLanguage"
}
}
val LocalAssistant = staticCompositionLocalOf<AssistantSettings> { error("No AssistantSettings") }
@Serializable
private data class ChatRequest(
val model: String,
val messages: List<ChatMessage>,
val stream: Boolean = false,
val temperature: Double = 0.2,
)
@Serializable
private data class ChatMessage(val role: String, val content: String)
@Serializable
private data class ChatResponse(val choices: List<Choice> = emptyList())
@Serializable
private data class Choice(val message: ChatMessage? = null)
/**
* Anthropic's own shape. Everything else worth talking to speaks the OpenAI one, so this is the
* single exception the client makes — a different path, a different header, and a reply that
* arrives as a list of content blocks rather than a message.
*/
@Serializable
private data class ClaudeRequest(
val model: String,
val system: String,
val messages: List<ChatMessage>,
@SerialName("max_tokens") val maxTokens: Int = 2048,
)
@Serializable
private data class ClaudeResponse(val content: List<ClaudeBlock> = emptyList())
@Serializable
private data class ClaudeBlock(val type: String = "", val text: String = "")
/**
* Talks to anything that speaks the OpenAI chat-completions shape, which Ollama, LM Studio,
* llama.cpp and LocalAI all do. That one shape is why no vendor library is needed: it is an
* HTTP POST with a JSON body, and the app already has an HTTP client and a JSON parser.
*/
object Assistant {
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
private val media = "application/json; charset=utf-8".toMediaType()
// A model running on a phone or an old laptop can take a while to answer.
private val http by lazy {
OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(180, TimeUnit.SECONDS)
.build()
}
/**
* Answers already received, so scrolling a poem does not ask twice for the same thing. A
* LazyColumn disposes what scrolls out of view, which restarts the request behind it; on a
* metered API that is money, and on a model running locally it is a wait the reader already
* sat through. Access-ordered, so the oldest falls out first.
*/
private val answers = object : LinkedHashMap<String, String>(16, 0.75f, true) {
override fun removeEldestEntry(eldest: Map.Entry<String, String>) = size > 32
}
internal fun cacheKey(prompt: String, language: AssistantLanguage, text: String) =
"$prompt|${language.code}|$text"
@Synchronized
internal fun cached(key: String): String? = answers[key]
@Synchronized
internal fun remember(key: String, reply: String) {
answers[key] = reply
}
/** Anthropic is the one service that does not speak the OpenAI shape. */
internal fun isClaude(baseUrl: String) = baseUrl.contains("anthropic.com", ignoreCase = true)
/** Pulls the reply out of whichever shape came back, so the callers never see the difference. */
internal fun reply(body: String, claude: Boolean): String {
val text = if (claude) {
json.decodeFromString<ClaudeResponse>(body)
.content.firstOrNull { it.type == "text" }?.text
} else {
json.decodeFromString<ChatResponse>(body)
.choices.firstOrNull()?.message?.content
}
return text?.trim().orEmpty().ifEmpty { throw IOException("empty reply") }
}
suspend fun ask(
settings: AssistantSettings,
system: String,
user: String,
): Result<String> = withContext(Dispatchers.IO) {
if (!settings.serverReady) {
return@withContext Result.failure(IllegalStateException("not configured"))
}
val claude = isClaude(settings.baseUrl)
val base = settings.baseUrl.trimEnd('/')
val body = if (claude) {
json.encodeToString(
ClaudeRequest(
model = settings.model,
system = system,
messages = listOf(ChatMessage("user", user)),
)
)
} else {
json.encodeToString(
ChatRequest(
model = settings.model,
messages = listOf(
ChatMessage("system", system),
ChatMessage("user", user),
),
)
)
}.toRequestBody(media)
val request = Request.Builder()
.url(if (claude) "$base/messages" else "$base/chat/completions")
.post(body)
.apply {
if (settings.apiKey.isBlank()) return@apply
if (claude) {
header("x-api-key", settings.apiKey)
header("anthropic-version", "2023-06-01")
} else {
header("Authorization", "Bearer ${settings.apiKey}")
}
}
.build()
runCatching {
http.newCall(request).execute().use { response ->
val text = response.body?.string().orEmpty()
if (!response.isSuccessful) throw IOException("HTTP ${response.code}: ${text.take(200)}")
reply(text, claude)
}
}
}
}
/** The instruction given to the model, kept here so it can be read and argued with. */
object Prompts {
private const val ROLE =
"You are helping someone read classical Persian poetry from Ganjoor. Answer only with " +
"what was asked, with no preamble and no notes about yourself."
fun system(language: AssistantLanguage) =
"$ROLE Reply in ${language.englishName}."
fun translate(text: String) = "Translate this Persian poetry faithfully:\n\n$text"
fun summarise(text: String) =
"Summarise what this Persian poem says, in a short paragraph:\n\n$text"
fun explain(couplet: String) =
"Explain the meaning and the imagery of this couplet:\n\n$couplet"
/** Ganjoor publishes its own AI summaries in Persian; this carries one across. */
fun translateSummary(summary: String) =
"Translate this Persian commentary on a poem:\n\n$summary"
}
@@ -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
)
@@ -0,0 +1,43 @@
package com.ganjoor.android.data
import android.content.Context
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.core.content.edit
/**
* The poets someone keeps to hand, by the url that identifies them.
*
* Kept in the order they were pinned rather than sorted: a shelf someone arranges themselves
* should stay where they put it, and a new pin appearing in the middle of the row is disorienting.
*
* ponytail: the same SharedPreferences and the same shape as [Bookmarks] — a list of a few strings
* doesn't need a database or a schema, and the two have no reason to diverge.
*/
class PinnedPoets(context: Context) {
private val prefs = context.getSharedPreferences("ganjoor", Context.MODE_PRIVATE)
val items = mutableStateListOf<String>().apply {
prefs.getString(KEY, null)
?.split(SEPARATOR)
?.filter { it.isNotBlank() }
?.let(::addAll)
}
fun contains(fullUrl: String) = fullUrl in items
fun toggle(fullUrl: String) {
if (!items.remove(fullUrl)) items.add(fullUrl)
// commit, so a pin survives the process being killed right after it's made.
prefs.edit(commit = true) { putString(KEY, items.joinToString(SEPARATOR)) }
}
private companion object {
const val KEY = "pinnedPoets"
/** A newline cannot appear in a Ganjoor url, so it needs no escaping. */
const val SEPARATOR = "\n"
}
}
val LocalPinnedPoets = staticCompositionLocalOf<PinnedPoets> { error("No PinnedPoets provided") }
@@ -137,6 +137,14 @@ private val CREDITS = listOf(
"apache-2.0.txt",
"https://google.github.io/accompanist/",
),
Credit(
"Material Symbols",
"The Android Open Source Project — the share, lookup and assistant glyphs, drawn from " +
"their path data rather than pulled in as a library",
"Apache License 2.0",
"apache-2.0.txt",
"https://fonts.google.com/icons",
),
Credit(
"Ganjoor for Android",
"This app",
@@ -173,6 +181,14 @@ fun AboutScreen(onUp: () -> Unit, onHome: () -> Unit) {
) { insets ->
LazyColumn(modifier = Modifier.fillMaxSize(), contentPadding = insets) {
item {
// Stated before anything else: readers arriving from ganjoor.net should not have
// to infer from the credits below that this is someone else's app.
Text(
text = stringResource(R.string.about_unofficial),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp),
)
Text(
text = stringResource(R.string.about_intro),
style = MaterialTheme.typography.bodyMedium,
@@ -0,0 +1,176 @@
package com.ganjoor.android.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.ganjoor.android.R
import com.ganjoor.android.data.Assistant
import com.ganjoor.android.data.AssistantLanguage
import com.ganjoor.android.data.LocalAssistant
import com.ganjoor.android.data.Prompts
/**
* Asking the reader's own assistant, and showing what comes back.
*
* The answer isn't stored: it's a reading aid, and a model's paraphrase of a thousand-year-old
* poem has no business being cached next to the poem itself. Sharing it on is one tap, for anyone
* who wants to keep it.
*/
/**
* The reply, in the page. A translation belongs under the thing it translates — leaving the poem
* to read it, whether to a sheet over the top or to another app entirely, breaks the reading.
*
* With a server configured the answer arrives here. Without one there is nothing to ask, so the
* question goes to whichever assistant is installed; that hand-off is the only path that leaves
* the app, and only because the alternative is no answer at all.
*/
@Composable
fun AssistantInline(prompt: String, text: String, label: Int, instruction: Int) {
val assistant = LocalAssistant.current
val context = LocalContext.current
val ask = stringResource(instruction)
var asked by remember(text) { mutableStateOf(false) }
Column(modifier = Modifier.fillMaxWidth()) {
if (!asked) {
TextButton(onClick = {
if (assistant.serverReady) asked = true else context.shareText("$ask\n\n$text")
}) {
Icon(
painter = painterResource(R.drawable.ic_ask),
contentDescription = null,
modifier = Modifier.size(18.dp).padding(end = 4.dp),
)
Text(stringResource(label))
}
}
AnimatedVisibility(visible = asked) {
AssistantReply(prompt = prompt, text = text)
}
}
}
/** The question and its answer, with nothing around them. */
@Composable
fun AssistantReply(prompt: String, text: String, modifier: Modifier = Modifier) {
val settings = LocalAssistant.current
val context = LocalContext.current
val language = settings.language
val key = Assistant.cacheKey(prompt, language, text)
val answer by produceState<Result<String>?>(Assistant.cached(key)?.let(Result.Companion::success), key) {
// Already answered once: an item scrolling back into view must not ask again.
if (value != null) return@produceState
val result = Assistant.ask(
settings = settings,
system = Prompts.system(language),
user = when (prompt) {
"translate" -> Prompts.translate(text)
"summarise" -> Prompts.summarise(text)
"explain" -> Prompts.explain(text)
"summary" -> Prompts.translateSummary(text)
else -> text
},
)
result.getOrNull()?.let { Assistant.remember(key, it) }
value = result
}
Column(modifier = modifier.fillMaxWidth().padding(top = 4.dp)) {
when (val result = answer) {
null -> Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(
strokeWidth = 2.dp,
modifier = Modifier.size(16.dp).padding(end = 8.dp),
)
Text(
text = stringResource(R.string.assistant_working),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
else -> if (result.isSuccess) {
// A reply in Urdu or English needs its own direction, not the reader's.
val reply = result.getOrDefault("")
InDirectionOf(reply) {
Text(
text = reply,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.fillMaxWidth(),
)
}
TextButton(onClick = { context.shareText(reply) }) {
Icon(
imageVector = Icons.Default.Share,
contentDescription = null,
modifier = Modifier.size(18.dp).padding(end = 4.dp),
)
Text(stringResource(R.string.share))
}
} else {
Text(
text = result.exceptionOrNull()?.message
?: stringResource(R.string.assistant_not_set_up),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}
}
/** The same reply as a sheet, for the selection-menu screen, which has no page to sit in. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AssistantResultSheet(prompt: String, text: String, onDismiss: () -> Unit) {
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
) {
Column(
modifier = Modifier
.navigationBarsPadding()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
) {
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 4,
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
)
AssistantReply(prompt = prompt, text = text)
}
}
}
@@ -0,0 +1,213 @@
package com.ganjoor.android.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
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.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import com.ganjoor.android.R
import com.ganjoor.android.data.ASSISTANT_PRESETS
import com.ganjoor.android.data.Assistant
import com.ganjoor.android.data.AssistantLanguage
import com.ganjoor.android.data.AssistantMode
import com.ganjoor.android.data.LocalAssistant
import com.ganjoor.android.data.Prompts
import kotlinx.coroutines.launch
/**
* Everything to do with the optional language model, on a page of its own because none of it is
* needed to read a poem.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AssistantScreen(onUp: () -> Unit, onHome: () -> Unit) {
val settings = LocalAssistant.current
val scope = rememberCoroutineScope()
var baseUrl by remember { mutableStateOf(settings.baseUrl) }
var model by remember { mutableStateOf(settings.model) }
var apiKey by remember { mutableStateOf(settings.apiKey) }
var testing by remember { mutableStateOf(false) }
var result by remember { mutableStateOf<String?>(null) }
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.assistant)) },
navigationIcon = {
IconButton(onClick = onUp) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back))
}
},
actions = { HomeAction(onHome) },
)
}
) { insets ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(insets)
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp)
.padding(bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = stringResource(R.string.assistant_intro),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 12.dp),
)
SectionLabel(R.string.assistant_mode)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AssistantMode.entries.forEach { option ->
FilterChip(
selected = settings.mode == option,
onClick = { settings.update(mode = option) },
label = { Text(stringResource(modeLabel(option))) },
)
}
}
Text(
text = stringResource(modeNote(settings.mode)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
SectionLabel(R.string.assistant_language)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AssistantLanguage.entries.forEach { option ->
FilterChip(
selected = settings.language == option,
onClick = { settings.update(language = option) },
label = { Text(option.englishName) },
)
}
}
if (settings.mode == AssistantMode.Server) {
HorizontalDivider(Modifier.padding(vertical = 8.dp))
SectionLabel(R.string.assistant_presets)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ASSISTANT_PRESETS.forEach { preset ->
SuggestionChip(
onClick = {
baseUrl = preset.baseUrl
model = preset.model
settings.update(baseUrl = preset.baseUrl, model = preset.model)
},
label = { Text(preset.name) },
)
}
}
OutlinedTextField(
value = baseUrl,
onValueChange = { baseUrl = it; settings.update(baseUrl = it) },
label = { Text(stringResource(R.string.assistant_base_url)) },
supportingText = { Text(stringResource(R.string.assistant_base_url_note)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = model,
onValueChange = { model = it; settings.update(model = it) },
label = { Text(stringResource(R.string.assistant_model)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = apiKey,
onValueChange = { apiKey = it; settings.update(apiKey = it) },
label = { Text(stringResource(R.string.assistant_api_key)) },
supportingText = { Text(stringResource(R.string.assistant_api_key_note)) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = {
testing = true
result = null
scope.launch {
val reply = Assistant.ask(
settings = settings,
system = Prompts.system(settings.language),
user = Prompts.translate("سلام"),
)
testing = false
result = reply.fold(
onSuccess = { it.take(160) },
onFailure = { it.message ?: "failed" },
)
}
},
enabled = !testing && settings.serverReady,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(if (testing) R.string.assistant_testing else R.string.assistant_test))
}
result?.let {
Text(it, style = MaterialTheme.typography.bodySmall)
}
}
HorizontalDivider(Modifier.padding(vertical = 8.dp))
Text(
text = stringResource(R.string.assistant_privacy),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
private fun modeLabel(mode: AssistantMode) = when (mode) {
AssistantMode.Off -> R.string.assistant_off
AssistantMode.Server -> R.string.assistant_server
AssistantMode.ShareToApp -> R.string.assistant_share
}
private fun modeNote(mode: AssistantMode) = when (mode) {
AssistantMode.Off -> R.string.assistant_off_note
AssistantMode.Server -> R.string.assistant_server_note
AssistantMode.ShareToApp -> R.string.assistant_share_note
}
@Composable
private fun SectionLabel(resId: Int) {
Text(
text = stringResource(resId),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 10.dp),
)
}
@@ -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) }
@@ -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
@@ -42,6 +57,9 @@ object DownloadsRoute
@Serializable
object AboutRoute
@Serializable
object AssistantRoute
/**
* Opens a page with the poet list as the only thing beneath it.
*
@@ -68,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 },
@@ -75,16 +95,45 @@ fun GanjoorApp() {
settingsOpen = false
nav.navigate(AboutRoute)
},
LocalOpenAssistant provides {
settingsOpen = false
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(
@@ -96,34 +145,73 @@ fun GanjoorApp() {
}
composable<CategoryRoute> { entry ->
val url = entry.toRoute<CategoryRoute>().url
CategoryScreen(
fullUrl = url,
onUp = { nav.goUp(url) },
onHome = { nav.goHome() },
onCategory = { nav.open(CategoryRoute(it)) },
onPoem = { nav.open(PoemRoute(it)) },
)
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) },
onHome = { nav.goHome() },
onCategory = { nav.open(CategoryRoute(it)) },
onPoem = { nav.open(PoemRoute(it)) },
)
}
}
composable<PoemRoute> { entry ->
val route = entry.toRoute<PoemRoute>()
PoemScreen(
fullUrl = route.url,
// Reading on through a divan keeps the origin, so Back still lands where
// you started rather than in whichever section you drifted into.
onUp = { if (route.fromBookmarks) nav.navigateUp() else nav.goUp(route.url) },
onHome = { nav.goHome() },
onPoem = { url ->
if (route.fromBookmarks) {
nav.navigate(PoemRoute(url, fromBookmarks = true)) {
popUpTo<PoemRoute> { inclusive = true }
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
// you started rather than in whichever section you drifted into.
onUp = { if (route.fromBookmarks) nav.navigateUp() else nav.goUp(route.url) },
onHome = { nav.goHome() },
onPoem = { url ->
if (route.fromBookmarks) {
nav.navigate(PoemRoute(url, fromBookmarks = true)) {
popUpTo<PoemRoute> { inclusive = true }
}
} else {
nav.open(PoemRoute(url))
}
} else {
nav.open(PoemRoute(url))
}
},
// Tapping a breadcrumb leaves the saved list behind and starts browsing.
onCategory = { nav.open(CategoryRoute(it)) },
)
},
// 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(
@@ -144,12 +232,28 @@ fun GanjoorApp() {
composable<DownloadsRoute> {
DownloadsScreen(onUp = { nav.goHome() })
}
composable<AssistantRoute> {
AssistantScreen(onUp = { nav.navigateUp() }, onHome = { nav.goHome() })
}
composable<AboutRoute> {
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
@@ -11,10 +24,14 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
@@ -39,18 +56,23 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.platform.LocalClipboardManager
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
import com.ganjoor.android.data.Poem
import com.ganjoor.android.data.PoemRef
import com.ganjoor.android.data.Verse
import com.ganjoor.android.data.couplets
@@ -64,12 +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)
var tappedWord by remember { mutableStateOf<String?>(null) }
// 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<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 = wide && maxWidth - DictionaryPanelWidth >= MinPageMeasure
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() }
@@ -84,17 +134,25 @@ 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 = {
IconButton(onClick = onUp) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back))
Row {
navigationToggle?.invoke()
IconButton(onClick = onUp) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back))
}
}
},
actions = {
HomeAction(onHome)
ShareAction(poem = poem, couplets = couplets)
BookmarkAction(
url = fullUrl,
title = poem.title,
@@ -109,24 +167,34 @@ fun PoemScreen(
// per-couplet actions save a passage with the reference attached, which a raw copy
// would lose.
//
// ponytail: no dictionary entry in the selection toolbar. Compose 1.10 stopped
// routing SelectionContainer through LocalTextToolbar — a custom TextToolbar is
// simply never asked to show — and the replacement, foundation's contextmenu
// package, is internal. Revisit when that becomes public API.
SelectionContainer {
// The system selection menu gets its own entries from ProcessTextActivity, not from
// 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.
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,
@@ -141,9 +209,11 @@ fun PoemScreen(
Couplet(
couplet = couplet,
style = style,
sideBySide = sideBySide,
showSummaries = prefs.showSummaries,
source = Bookmark(fullUrl, poem.title, poem.fullTitle),
onWord = { tappedWord = it },
onWord = { tapped = it },
tapped = tapped,
)
}
@@ -173,11 +243,80 @@ fun PoemScreen(
}
}
}
}
}
}
}
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 },
)
}
}
}
}
}
tappedWord?.let { word ->
WordSheet(word = word, onDismiss = { tappedWord = 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))
}
}
}
@@ -211,6 +350,27 @@ private fun Breadcrumbs(fullTitle: String, fullUrl: String, onCategory: (String)
}
}
/**
* Shares the poem as text, with its title and link. The verses are joined couplet by couplet so
* the shape survives in apps that know nothing about Persian prosody.
*/
@Composable
private fun ShareAction(poem: Poem, couplets: List<List<Verse>>) {
val context = LocalContext.current
IconButton(onClick = {
val body = couplets.joinToString("\n\n") { couplet ->
couplet.joinToString("\n") { it.text }
}
context.shareText(
text = "${poem.fullTitle}\n\n$body",
url = poem.fullUrl,
subject = poem.fullTitle,
)
}) {
Icon(Icons.Default.Share, stringResource(R.string.share_poem))
}
}
@Composable
private fun BookmarkAction(url: String, title: String, subtitle: String) {
val bookmarks = LocalBookmarks.current
@@ -236,20 +396,63 @@ private fun Couplet(
style: androidx.compose.ui.text.TextStyle,
showSummaries: Boolean,
source: Bookmark,
onWord: (String) -> 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)) {
couplet.forEach { verse ->
VerseText(
verse = verse,
style = style,
onWord = onWord,
// A tap that lands between words still opens the couplet's own actions.
onElsewhere = { actionsOpen = !actionsOpen },
)
// 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 = { 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 }
@@ -259,12 +462,49 @@ private fun Couplet(
text = summary,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp, bottom = 8.dp),
modifier = Modifier.padding(top = 4.dp),
)
// Ganjoor writes these in Persian. Offered only once an assistant is set up:
// a button under every couplet earns its space only if it can answer, and the
// couplet's own actions are behind a tap between words that few will find.
if (LocalAssistant.current.serverReady) {
AssistantInline(
prompt = "summary",
text = summary,
label = R.string.assistant_translate,
instruction = R.string.assistant_translate_prompt,
)
}
}
}
// A visible way in. The tap between words still works, but on a full line of poetry it
// almost never lands there, so the actions were effectively unreachable without this.
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
IconButton(
onClick = { actionsOpen = !actionsOpen },
modifier = Modifier.size(32.dp),
) {
Icon(
imageVector = if (actionsOpen) Icons.Default.KeyboardArrowUp
else Icons.Default.KeyboardArrowDown,
contentDescription = stringResource(R.string.couplet_options),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
}
}
if (actionsOpen) {
PassageActions(source.copy(excerpt = couplet.joinToString("\n") { it.text }))
PassageActions(passage)
// Below the buttons rather than among them: the answer needs the full width.
AssistantInline(
prompt = "explain",
text = passage.excerpt.orEmpty(),
label = R.string.assistant_explain,
instruction = R.string.assistant_ask_prompt,
)
}
}
}
@@ -273,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
@@ -294,7 +552,7 @@ private fun VerseText(
else -> TextAlign.Justify
},
onTextLayout = { layout = it },
modifier = Modifier
modifier = modifier
.fillMaxWidth()
.pointerInput(verse.text) {
detectTapGestures { position ->
@@ -306,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 —
@@ -318,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))
@@ -327,14 +585,15 @@ 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. */
@Composable
private fun PassageActions(passage: Bookmark) {
internal fun PassageActions(passage: Bookmark) {
val bookmarks = LocalBookmarks.current
val clipboard = LocalClipboardManager.current
val context = LocalContext.current
val saved = bookmarks.contains(passage)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -352,6 +611,22 @@ private fun PassageActions(passage: Bookmark) {
}) {
Text(stringResource(R.string.copy))
}
TextButton(onClick = {
context.shareText(
text = passage.excerpt.orEmpty(),
url = passage.url,
subject = passage.title,
)
}) {
// The same glyph as the top bar and as every other Android app: share is a shape
// people recognise before they read the word next to it.
Icon(
imageVector = Icons.Default.Share,
contentDescription = null,
modifier = Modifier.size(18.dp).padding(end = 4.dp),
)
Text(stringResource(R.string.share))
}
}
}
@@ -370,6 +645,12 @@ private fun PoemSummary(summary: String) {
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
AssistantInline(
prompt = "summary",
text = summary,
label = R.string.assistant_translate,
instruction = R.string.assistant_translate_prompt,
)
}
}
@@ -2,6 +2,10 @@ package com.ganjoor.android.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import com.ganjoor.android.data.LocalPinnedPoets
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items as listItems
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -51,6 +55,11 @@ import coil3.compose.AsyncImage
import java.text.Collator
import java.util.Locale
import com.ganjoor.android.R
import com.ganjoor.android.ui.theme.downloaded
import com.ganjoor.android.data.Offline
import com.ganjoor.android.data.Downloads
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material.icons.filled.CheckCircle
import com.ganjoor.android.data.Ganjoor
import com.ganjoor.android.data.PoetRef
@@ -70,6 +79,7 @@ fun PoetsScreen(
TopAppBar(
title = { Text(stringResource(R.string.app_name)) },
actions = {
ViewAction()
LanguageAction()
IconButton(onClick = onBookmarks) {
Icon(Icons.Default.Favorite, stringResource(R.string.bookmarks))
@@ -89,42 +99,38 @@ 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 poets = remember(query, manifest, sort) {
val pinned = LocalPinnedPoets.current
val poets = remember(query, manifest, sort, pinned.items.toList()) {
val matches =
if (query.isBlank()) manifest.poets
else manifest.poets.filter {
it.nickname.contains(query.trim(), ignoreCase = true)
}
// Persian letters don't sort correctly by code point (آ vs ا, ی vs ي), so hand
// the ordering to a collator rather than String.compareTo.
if (sort == PoetSort.Name) {
val collator = Collator.getInstance(Locale.forLanguageTag("fa"))
matches.sortedWith { a, b -> collator.compare(a.nickname, b.nickname) }
} else {
matches
}
orderPoets(matches, sort, pinned.items)
}
LazyVerticalGrid(
columns = GridCells.Adaptive(132.dp),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 12.dp,
end = 12.dp,
top = insets.calculateTopPadding() + 8.dp,
bottom = insets.calculateBottomPadding() + 16.dp,
),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item(span = { GridItemSpan(maxLineSpan) }) {
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
val padding = PaddingValues(
start = 12.dp,
end = 12.dp,
top = insets.calculateTopPadding() + 8.dp,
bottom = insets.calculateBottomPadding() + 16.dp,
)
// The chips and the hint are the same whichever shape the poets take.
val header: @Composable () -> Unit = {
Column(modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
PoetSort.entries.forEach { option ->
FilterChip(
selected = sort == option,
@@ -133,9 +139,51 @@ fun PoetsScreen(
)
}
}
// Only while the shelf is empty: once there is something on it, it explains
// itself, and a standing instruction is just clutter.
if (sort == PoetSort.Pinned && pinned.items.isEmpty()) {
Text(
text = stringResource(R.string.pin_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
}
}
items(poets, key = { it.id }) { poet ->
PoetCard(poet) { onPoet(poet.fullUrl) }
}
if (settings.value.poetGrid) {
LazyVerticalGrid(
columns = GridCells.Adaptive(132.dp),
modifier = Modifier.fillMaxSize(),
contentPadding = padding,
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item(span = { GridItemSpan(maxLineSpan) }) { header() }
items(poets, key = { it.id }) { poet ->
PoetCard(
poet = poet,
pinned = pinned.contains(poet.fullUrl),
onClick = { onPoet(poet.fullUrl) },
onPin = { pinned.toggle(poet.fullUrl) },
)
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = padding,
) {
item { header() }
listItems(poets, key = { it.id }) { poet ->
PoetRow(
poet = poet,
pinned = pinned.contains(poet.fullUrl),
onClick = { onPoet(poet.fullUrl) },
onPin = { pinned.toggle(poet.fullUrl) },
)
}
}
}
}
@@ -176,9 +224,113 @@ private fun SearchBar(query: String, onQueryChange: (String) -> Unit, onSearchPo
}
}
/** Switches the poets between cards and a list, and remembers which. */
@Composable
private fun PoetCard(poet: PoetRef, onClick: () -> Unit) {
Card(modifier = Modifier.clickable(onClick = onClick)) {
private fun ViewAction() {
val settings = LocalSettings.current
val grid = settings.value.poetGrid
IconButton(onClick = { settings.update { it.copy(poetGrid = !grid) } }) {
Icon(
painter = painterResource(
if (grid) R.drawable.ic_view_list else R.drawable.ic_view_grid
),
contentDescription = stringResource(if (grid) R.string.view_list else R.string.view_grid),
)
}
}
/** The pin marker, shown only on a poet someone chose to keep. */
@Composable
private fun PinMark(modifier: Modifier = Modifier) {
Icon(
painter = painterResource(R.drawable.ic_pin),
contentDescription = stringResource(R.string.unpin_poet),
tint = MaterialTheme.colorScheme.primary,
modifier = modifier.size(16.dp),
)
}
/**
* Fetch a poet's poems, or say they are already here. Three states and no menu: downloading shows
* its progress and cancels on a tap, downloaded is a green tick, and anything else offers the
* download. Deleting stays on the downloads page, where the sizes are — a tap next to a poet's
* name should never be the thing that throws their poems away.
*/
@Composable
private fun DownloadAction(slug: String) {
val progress = Downloads.running[slug]
val saved = remember(slug, Downloads.revision) { Offline.isSaved(slug) }
when {
progress != null -> IconButton(onClick = { Downloads.cancel(slug) }) {
CircularProgressIndicator(
strokeWidth = 2.dp,
modifier = Modifier.size(18.dp),
)
}
// In a box the size of an IconButton, even though nothing here is tappable: a bare icon
// sits where the button's padding would have put it, so the ticks and the arrows would
// not line up down the column.
saved -> Box(
modifier = Modifier.size(48.dp),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = stringResource(R.string.downloaded_poet),
tint = MaterialTheme.colorScheme.downloaded,
)
}
else -> IconButton(onClick = { Downloads.start(slug) }) {
Icon(
painter = painterResource(R.drawable.ic_download),
contentDescription = stringResource(R.string.download),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
/** One poet as a row, for readers who would rather scan names than faces. */
@Composable
private fun PoetRow(poet: PoetRef, pinned: Boolean, onClick: () -> Unit, onPin: () -> Unit) {
ListItem(
headlineContent = { Text(poet.nickname, style = MaterialTheme.typography.titleMedium) },
leadingContent = {
Box(
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.secondaryContainer),
contentAlignment = Alignment.Center,
) {
Text(poet.nickname.take(1), color = MaterialTheme.colorScheme.onSecondaryContainer)
AsyncImage(
model = poet.imageUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
}
},
trailingContent = {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (pinned) PinMark()
DownloadAction(poet.slug)
}
},
modifier = Modifier.combinedClickable(onClick = onClick, onLongClick = onPin),
)
}
@Composable
private fun PoetCard(poet: PoetRef, pinned: Boolean, onClick: () -> Unit, onPin: () -> Unit) {
Card(modifier = Modifier.combinedClickable(onClick = onClick, onLongClick = onPin)) {
Column(
modifier = Modifier.fillMaxWidth().padding(12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -204,13 +356,48 @@ private fun PoetCard(poet: PoetRef, onClick: () -> Unit) {
modifier = Modifier.fillMaxSize(),
)
}
Text(
text = poet.nickname,
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (pinned) PinMark()
Text(
text = poet.nickname,
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
/** 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.
*
* With nothing pinned this is Ganjoor's order untouched, which is what lets it be the default
* without anyone opening the app to an empty screen.
*/
internal fun pinnedFirst(poets: List<PoetRef>, pins: List<String>): List<PoetRef> {
if (pins.isEmpty()) return poets
val (kept, rest) = poets.partition { it.fullUrl in pins }
return kept.sortedBy { pins.indexOf(it.fullUrl) } + rest
}
@@ -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
@@ -44,6 +52,7 @@ val LocalOpenReadingSettings = staticCompositionLocalOf<() -> Unit> {
/** Opens the About and licences screen; the sheet is the only place that needs it. */
val LocalOpenAbout = staticCompositionLocalOf<() -> Unit> { error("No about host") }
val LocalOpenAssistant = staticCompositionLocalOf<() -> Unit> { error("No assistant host") }
/** Top-bar button that opens the reading settings sheet. */
@Composable
@@ -57,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,
@@ -137,15 +188,16 @@ fun ReadingSettingsSheet(onDismiss: () -> Unit) {
settings.update { it.copy(showSummaries = on) }
}
val openAssistant = LocalOpenAssistant.current
TextButton(onClick = openAssistant, modifier = Modifier.padding(top = 16.dp)) {
Text(stringResource(R.string.assistant_title))
}
val openAbout = LocalOpenAbout.current
TextButton(
onClick = openAbout,
modifier = Modifier.padding(top = 16.dp),
) {
TextButton(onClick = openAbout) {
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,50 +125,96 @@ 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 }
}
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
IconButton(onClick = ::toggle) {
when {
loading -> CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
// Core Material icons ship no pause glyph, and the extended set is 4 MB for one.
playing -> Icon(painterResource(R.drawable.ic_pause), stringResource(R.string.pause))
else -> Icon(Icons.Default.PlayArrow, stringResource(R.string.play_recitation))
Column(modifier.fillMaxWidth().padding(bottom = 8.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
IconButton(onClick = ::toggle) {
when {
loading -> CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
// Core Material icons ship no pause glyph, and the extended set is 4 MB for one.
playing -> Icon(painterResource(R.drawable.ic_pause), stringResource(R.string.pause))
else -> Icon(Icons.Default.PlayArrow, stringResource(R.string.play_recitation))
}
}
TextButton(
onClick = { if (recitations.size > 1) picking = true },
modifier = Modifier.weight(1f, fill = false),
) {
Text(
text = recitation.audioArtist.ifBlank { stringResource(R.string.play_recitation) },
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (recitations.size > 1) {
Text(
text = "${chosen + 1}/${recitations.size}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
DropdownMenu(expanded = picking, onDismissRequest = { picking = false }) {
recitations.forEachIndexed { index, item ->
DropdownMenuItem(
text = { Text(item.audioArtist.ifBlank { item.audioTitle }) },
onClick = { chosen = index; picking = false },
)
}
}
}
TextButton(
onClick = { if (recitations.size > 1) picking = true },
modifier = Modifier.weight(1f, fill = false),
) {
Text(
text = recitation.audioArtist.ifBlank { stringResource(R.string.play_recitation) },
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (recitations.size > 1) {
Text(
text = "${chosen + 1}/${recitations.size}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
DropdownMenu(expanded = picking, onDismissRequest = { picking = false }) {
recitations.forEachIndexed { index, item ->
DropdownMenuItem(
text = { Text(item.audioArtist.ifBlank { item.audioTitle }) },
onClick = { chosen = index; picking = false },
)
// 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,
)
}
}
}
}
@@ -42,6 +42,12 @@ enum class Language(val tag: String, val label: String) {
/** How the poet grid is ordered. */
enum class PoetSort(@StringRes val label: Int) {
/**
* Whoever the reader pinned, first. With nothing pinned it is Ganjoor's order, so this can be
* the default without anyone ever opening the app to an empty screen.
*/
Pinned(R.string.sort_pinned),
/** The data set's own order, which is Ganjoor's — best known poets first. */
Default(R.string.sort_default),
Name(R.string.sort_name),
@@ -57,7 +63,10 @@ data class Prefs(
val offline: Boolean = false,
/** True black backgrounds, applied to whichever dark theme is in use. */
val oled: Boolean = false,
val poetSort: PoetSort = PoetSort.Default,
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. */
@@ -75,7 +84,9 @@ class Settings(context: Context) {
offline = prefs.getBoolean("offline", false),
// "Black" used to be a sixth theme; it is a flag on the dark ones now.
oled = prefs.getBoolean("oled", prefs.getString("theme", null) == "Black"),
poetSort = enumOrDefault(prefs.getString("poetSort", null), PoetSort.Default),
poetSort = enumOrDefault(prefs.getString("poetSort", null), PoetSort.Pinned),
poetGrid = prefs.getBoolean("poetGrid", true),
columnsHidden = prefs.getBoolean("columnsHidden", false),
)
)
private set
@@ -94,6 +105,8 @@ class Settings(context: Context) {
putBoolean("offline", p.offline)
putBoolean("oled", p.oled)
putString("poetSort", p.poetSort.name)
putBoolean("poetGrid", p.poetGrid)
putBoolean("columnsHidden", p.columnsHidden)
}
}
}
@@ -0,0 +1,30 @@
package com.ganjoor.android.ui
import android.content.Context
import android.content.Intent
import com.ganjoor.android.R
/**
* Hands text to whatever the reader has installed: a messaging app, a notes app, a translator, or
* an AI assistant.
*
* ACTION_SEND is the entire mechanism. The system draws the chooser, so the app needs no list of
* apps, no per-vendor integration, no permission, and nothing to update when the reader installs
* something new — which is also why this is the one way to reach a proprietary assistant that
* stays within F-Droid's rules: the app ships no code belonging to it.
*/
fun Context.shareText(text: String, url: String? = null, subject: String? = null) {
// The link matters for poetry: a couplet with no reference is a quote nobody can look up.
val body = url?.let { "$text\n\n${webUrl(it)}" } ?: text
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, body)
if (!subject.isNullOrBlank()) putExtra(Intent.EXTRA_SUBJECT, subject)
}
startActivity(Intent.createChooser(intent, getString(R.string.share)))
}
/** Ganjoor paths are stored as site-relative, the way the API gives them. */
fun webUrl(fullUrl: String): String =
if (fullUrl.startsWith("http")) fullUrl
else "https://ganjoor.net/" + fullUrl.trim('/')
@@ -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)
}
}
}
@@ -2,7 +2,12 @@ package com.ganjoor.android.ui
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
@@ -12,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
@@ -31,10 +42,28 @@ import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import com.ganjoor.android.R
import com.ganjoor.android.data.Definition
import com.ganjoor.android.data.Bookmark
import com.ganjoor.android.data.Dictionary
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
/** English prose inside an otherwise right-to-left sheet. */
@Composable
private fun LeftToRight(content: @Composable () -> Unit) {
@@ -43,7 +72,7 @@ private fun LeftToRight(content: @Composable () -> Unit) {
/** Lays a definition out the way its own script reads. */
@Composable
private fun InDirectionOf(text: String, content: @Composable () -> Unit) {
internal fun InDirectionOf(text: String, content: @Composable () -> Unit) {
val arabicScript = text.count { it in '\u0600'..'\u06FF' }
val latin = text.count { it in 'A'..'Z' || it in 'a'..'z' }
CompositionLocalProvider(
@@ -62,10 +91,87 @@ private fun sourceLabel(source: String) = when (source) {
else -> R.string.source_daneshjoo
}
/** What the dictionary knows about a tapped word. */
/**
* What the dictionary knows about a tapped word, as a sheet over the poem.
*
* When the word came from a couplet, that couplet's own actions sit at the foot. Tapping a word
* is the one gesture every reader finds; saving, copying or sharing the line used to be behind a
* tap that landed between words, which on a full line of poetry almost never happens.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WordSheet(word: String, onDismiss: () -> Unit) {
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) { 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),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
PassageActions(passage)
if (LocalAssistant.current.serverReady) {
AssistantInline(
prompt = "explain",
text = passage.excerpt.orEmpty(),
label = R.string.assistant_explain,
instruction = R.string.assistant_ask_prompt,
)
}
}
/**
* The lookup itself. Split out from the sheet because the selection-menu activity shows the same
* thing as a plain screen: it launches in its own task, so there is nothing behind it for a sheet
* to sit over, and a scrim with no content under it is just a grey window.
*/
@Composable
fun WordLookup(word: String, footer: @Composable ColumnScope.() -> Unit = {}) {
val prefs = LocalSettings.current.value
// A suggestion replaces what is being looked up, so the sheet can be followed like a trail.
var current by remember(word) { mutableStateOf(word) }
@@ -79,8 +185,7 @@ fun WordSheet(word: String, onDismiss: () -> Unit) {
value = if (definitions?.isEmpty() == true) Dictionary.suggest(current) else emptyList()
}
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
Column(
modifier = Modifier
.navigationBarsPadding()
.verticalScroll(rememberScrollState())
@@ -182,6 +287,8 @@ fun WordSheet(word: String, onDismiss: () -> Unit) {
}
}
}
}
footer()
}
}
@@ -9,6 +9,7 @@ import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalView
@@ -202,3 +203,13 @@ fun GanjoorTheme(
content = content,
)
}
/**
* The green on a poet whose poems are already on the device.
*
* Material's palette has no success colour, and a fixed green goes muddy on the sepia schemes and
* glares on OLED black. Picking by the surface's luminance covers all five schemes without each
* of them having to declare one.
*/
val ColorScheme.downloaded: Color
get() = if (surface.luminance() < 0.5f) Color(0xFF81C784) else Color(0xFF2E7D32)
+15
View File
@@ -0,0 +1,15 @@
<!--
Material "auto_awesome" — the sparkle that has become the generic sign for an AI action, the
same shape Gemini and the rest use. Theirs is a trademark; this is Material's own, under the
Apache licence already covering the icons the app ships.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?android:attr/textColorPrimary">
<path
android:fillColor="@android:color/white"
android:pathData="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" />
</vector>
+11
View File
@@ -0,0 +1,11 @@
<!-- Material "search", for the dictionary entry. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?android:attr/textColorPrimary">
<path
android:fillColor="@android:color/white"
android:pathData="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" />
</vector>
@@ -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>
+10
View File
@@ -0,0 +1,10 @@
<!-- Material "push_pin", marking a poet kept on the home screen. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="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" />
</vector>
+11
View File
@@ -0,0 +1,11 @@
<!-- Material "share" — the standard Android share glyph. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?android:attr/textColorPrimary">
<path
android:fillColor="@android:color/white"
android:pathData="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" />
</vector>
@@ -0,0 +1,10 @@
<!-- Material "grid_view", for switching the poets back to cards. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M3,3v8h8V3H3zM9,9H5V5h4V9zM3,13v8h8v-8H3zM9,19H5v-4h4V19zM13,3v8h8V3H13zM19,9h-4V5h4V9zM13,13v8h8v-8H13zM19,19h-4v-4h4V19z" />
</vector>
@@ -0,0 +1,11 @@
<!-- Material "view_list", for switching the poets to a list of names. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:pathData="M3,14h4v-4H3V14zM3,19h4v-4H3V19zM3,9h4V5H3V9zM8,14h13v-4H8V14zM8,19h13v-4H8V19zM8,5v4h13V5H8z" />
</vector>
+47
View File
@@ -59,6 +59,7 @@
<string name="load_more">بیشتر</string>
<string name="done">تمام</string>
<string name="about">درباره و پروانه‌ها</string>
<string name="about_unofficial">غیررسمی. «گنجور برای اندروید» یک کارخواه اندرویدیِ مستقل و غیررسمی برای ganjoor.net است. این برنامه پروژه‌ای جدا از ganjoor.net و مخزن‌های گیت‌هاب آن است و به‌دست محمد انس رشید ساخته و نگهداری می‌شود. صاحبان ganjoor.net آن را اداره یا مدیریت نمی‌کنند.</string>
<string name="about_intro">گنجور برای اندروید نرم‌افزار آزاد است. شعرها، قلم‌ها و همهٔ کتابخانه‌هایی که این برنامه بر آن‌ها ساخته شده در زیر آمده‌اند؛ برای خواندن متن کامل پروانه روی هر مورد بزنید.</string>
<string name="no_definition">برای این واژه مدخلی یافت نشد.</string>
<string name="source_wiktionary">ویکی‌واژه — فارسی به انگلیسی (CC BY-SA 3.0)</string>
@@ -72,4 +73,50 @@
<string name="oled_note">روی پوسته‌های تاریک اعمال می‌شود و در نمایشگر OLED باتری کمتری می‌برد</string>
<string name="play_recitation">پخش خوانش</string>
<string name="pause">توقف</string>
<string name="assistant">دستیار هوش مصنوعی</string>
<string name="assistant_intro">اختیاری است. همهٔ بخش‌های برنامه بدون آن کار می‌کنند. تا خودتان تنظیم نکنید چیزی به جایی فرستاده نمی‌شود و هیچ حساب یا کلیدی همراه برنامه نیست.</string>
<string name="assistant_mode">شیوهٔ دسترسی به مدل</string>
<string name="assistant_off">خاموش</string>
<string name="assistant_off_note">بدون دستیار. خلاصه‌های فارسی خود گنجور، اگر در تنظیمات خواندن روشن باشند، نمایش داده می‌شوند.</string>
<string name="assistant_server">سرور خودتان</string>
<string name="assistant_server_note">با هر چیزی که API گفت‌وگوی OpenAI را بفهمد کار می‌کند — Ollama، LM Studio، llama.cpp، LocalAI — و با کلاد که API خودش را دارد.</string>
<string name="assistant_share">ارسال به یک برنامه</string>
<string name="assistant_share_note">متن را به هر دستیاری که روی گوشی دارید می‌سپارد. تا خودتان یکی را انتخاب نکنید چیزی از این برنامه بیرون نمی‌رود.</string>
<string name="assistant_language">پاسخ به زبان</string>
<string name="assistant_presets">آماده‌ها</string>
<string name="assistant_base_url">نشانی پایه</string>
<string name="assistant_base_url_note">Ollama: http://127.0.0.1:11434/v1 · LM Studio: http://127.0.0.1:1234/v1 · OpenAI: https://api.openai.com/v1 · DeepSeek: https://api.deepseek.com/v1 · Gemini: https://generativelanguage.googleapis.com/v1beta/openai · Claude: https://api.anthropic.com/v1</string>
<string name="assistant_model">مدل</string>
<string name="assistant_api_key">کلید API (اختیاری)</string>
<string name="assistant_api_key_note">سرور روی دستگاه خودتان معمولاً کلید نمی‌خواهد. بدون رمزگذاری روی همین دستگاه ذخیره می‌شود.</string>
<string name="assistant_test">آزمایش اتصال</string>
<string name="assistant_testing">در حال آزمایش…</string>
<string name="assistant_privacy">وقتی ترجمه یا خلاصه می‌خواهید، تنها همان بیت یا شعری که انتخاب کرده‌اید به نشانی‌ای که تنظیم کرده‌اید فرستاده می‌شود.</string>
<string name="assistant_translate">ترجمه</string>
<string name="assistant_summarise">خلاصه</string>
<string name="assistant_explain">شرح این بیت</string>
<string name="assistant_translate_summary">ترجمهٔ خلاصهٔ گنجور</string>
<string name="assistant_not_set_up">برای استفاده، دستیار را در تنظیمات راه‌اندازی کنید.</string>
<string name="assistant_working">در حال پرسش…</string>
<string name="look_up">جست‌وجو در لغت‌نامه</string>
<string name="share">هم‌رسانی</string>
<string name="share_poem">هم‌رسانی این شعر</string>
<string name="selection_actions">چه کاری انجام شود؟</string>
<string name="assistant_ask">پرسش از دستیار</string>
<string name="assistant_ask_prompt">این شعر فارسی را ترجمه کن و معنایش را توضیح بده:</string>
<string name="assistant_title">هوش مصنوعی</string>
<string name="assistant_translate_prompt">این متن را ترجمه کن:</string>
<string name="this_couplet">این بیت</string>
<string name="couplet_options">گزینه‌های این بیت</string>
<string name="sort_pinned">برگزیده</string>
<string name="pin_poet">نگه‌داشتن در بالا</string>
<string name="unpin_poet">برداشتن</string>
<string name="pin_hint">برای نگه‌داشتن یک شاعر در اینجا، انگشت را روی او نگه دارید</string>
<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>
@@ -10,4 +10,5 @@
<item name="android:windowBackground">@color/window_background</item>
<item name="android:windowSplashScreenBackground">@color/window_background</item>
</style>
</resources>
+1
View File
@@ -4,4 +4,5 @@
<style name="Theme.Ganjoorappandroid" parent="android:Theme.Material.NoActionBar">
<item name="android:windowBackground">@color/window_background</item>
</style>
</resources>
+47
View File
@@ -59,6 +59,7 @@
<string name="load_more">مزید</string>
<string name="done">مکمل</string>
<string name="about">تعارف اور لائسنس</string>
<string name="about_unofficial">غیر سرکاری۔ ”گنجور فار اینڈرائیڈ“ ganjoor.net کا ایک خودمختار اور غیر سرکاری اینڈرائیڈ کلائنٹ ہے۔ یہ ganjoor.net اور اس کی گِٹ ہب ریپازٹریز سے الگ منصوبہ ہے، جسے محمد انس رشید نے بنایا اور وہی اسے سنبھالتے ہیں۔ ganjoor.net کے مالکان اسے نہ چلاتے ہیں اور نہ اس کا انتظام کرتے ہیں۔</string>
<string name="about_intro">گنجور فار اینڈرائیڈ آزاد سافٹ ویئر ہے۔ کلام، فونٹس اور تمام لائبریریاں ذیل میں درج ہیں؛ مکمل لائسنس پڑھنے کے لیے کسی اندراج پر ٹیپ کریں۔</string>
<string name="no_definition">اس لفظ کا کوئی اندراج نہیں ملا۔</string>
<string name="source_wiktionary">ویکی لغت — فارسی سے انگریزی (CC BY-SA 3.0)</string>
@@ -72,4 +73,50 @@
<string name="oled_note">تاریک تھیمز پر لاگو ہوتا ہے؛ OLED اسکرین پر بیٹری بچاتا ہے</string>
<string name="play_recitation">قرات سنیں</string>
<string name="pause">وقفہ</string>
<string name="assistant">مصنوعی ذہانت کا معاون</string>
<string name="assistant_intro">اختیاری ہے۔ ایپ کا ہر حصہ اس کے بغیر کام کرتا ہے۔ جب تک آپ خود ترتیب نہ دیں کچھ کہیں نہیں بھیجا جاتا، اور ایپ کے ساتھ کوئی اکاؤنٹ یا کلید نہیں آتی۔</string>
<string name="assistant_mode">ماڈل تک رسائی کا طریقہ</string>
<string name="assistant_off">بند</string>
<string name="assistant_off_note">کوئی معاون نہیں۔ گنجور کے اپنے فارسی خلاصے، اگر مطالعے کی ترتیبات میں آن ہوں، پھر بھی دکھائے جائیں گے۔</string>
<string name="assistant_server">آپ کا اپنا سرور</string>
<string name="assistant_server_note">ہر اس چیز سے بات کرتا ہے جو OpenAI چیٹ API سمجھتی ہو — Ollama، LM Studio، llama.cpp، LocalAI — اور Claude سے بھی، جس کا اپنا API ہے۔</string>
<string name="assistant_share">کسی ایپ کو بھیجیں</string>
<string name="assistant_share_note">متن آپ کے فون پر موجود کسی بھی معاون کو دے دیتا ہے۔ جب تک آپ خود انتخاب نہ کریں کچھ باہر نہیں جاتا۔</string>
<string name="assistant_language">جواب کی زبان</string>
<string name="assistant_presets">تیار ترتیبات</string>
<string name="assistant_base_url">بنیادی یو آر ایل</string>
<string name="assistant_base_url_note">Ollama: http://127.0.0.1:11434/v1 · LM Studio: http://127.0.0.1:1234/v1 · OpenAI: https://api.openai.com/v1 · DeepSeek: https://api.deepseek.com/v1 · Gemini: https://generativelanguage.googleapis.com/v1beta/openai · Claude: https://api.anthropic.com/v1</string>
<string name="assistant_model">ماڈل</string>
<string name="assistant_api_key">API کلید (اختیاری)</string>
<string name="assistant_api_key_note">آپ کی اپنی مشین کے سرور کو عموماً کلید کی ضرورت نہیں۔ اسی آلے پر بغیر خفیہ کاری محفوظ ہوتی ہے۔</string>
<string name="assistant_test">کنکشن جانچیں</string>
<string name="assistant_testing">جانچ جاری…</string>
<string name="assistant_privacy">جب آپ ترجمہ یا خلاصہ مانگتے ہیں تو صرف وہی شعر یا کلام بھیجا جاتا ہے جو آپ نے منتخب کیا۔</string>
<string name="assistant_translate">ترجمہ</string>
<string name="assistant_summarise">خلاصہ</string>
<string name="assistant_explain">اس شعر کی تشریح</string>
<string name="assistant_translate_summary">گنجور کے خلاصے کا ترجمہ</string>
<string name="assistant_not_set_up">استعمال کے لیے ترتیبات میں معاون قائم کریں۔</string>
<string name="assistant_working">پوچھا جا رہا ہے…</string>
<string name="look_up">لغت میں تلاش</string>
<string name="share">شیئر</string>
<string name="share_poem">یہ کلام شیئر کریں</string>
<string name="selection_actions">آپ کیا کرنا چاہیں گے؟</string>
<string name="assistant_ask">اسسٹنٹ سے پوچھیں</string>
<string name="assistant_ask_prompt">اس فارسی کلام کا ترجمہ کریں اور اس کا مطلب بیان کریں:</string>
<string name="assistant_title">مصنوعی ذہانت</string>
<string name="assistant_translate_prompt">اس کا ترجمہ کریں:</string>
<string name="this_couplet">یہ شعر</string>
<string name="couplet_options">اس شعر کے اختیارات</string>
<string name="sort_pinned">منتخب</string>
<string name="pin_poet">اوپر رکھیں</string>
<string name="unpin_poet">ہٹا دیں</string>
<string name="pin_hint">کسی شاعر کو یہاں رکھنے کے لیے اُس پر دبا کر رکھیں</string>
<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>
+1
View File
@@ -10,4 +10,5 @@
<item name="android:windowBackground">@color/window_background</item>
<item name="android:windowSplashScreenBackground">@color/window_background</item>
</style>
</resources>
+47
View File
@@ -64,6 +64,7 @@
<string name="load_more">Load more</string>
<string name="done">Done</string>
<string name="about">About &amp; licences</string>
<string name="about_unofficial">Unofficial. Ganjoor for Android is an independent, unofficial Android client for ganjoor.net. It is a separate project from ganjoor.net and its GitHub repositories, built and maintained by Muhammad Anas Rashid. The owners of ganjoor.net do not run or manage it.</string>
<string name="about_intro">Ganjoor for Android is free software. The poems, the fonts and every library it is built on are credited below; tap an entry to read its full licence.</string>
<string name="no_definition">No entry for this word.</string>
<string name="source_wiktionary">Wiktionary — Persian to English (CC BY-SA 3.0)</string>
@@ -77,4 +78,50 @@
<string name="oled_note">Applies to the dark themes; saves power on OLED screens</string>
<string name="play_recitation">Play recitation</string>
<string name="pause">Pause</string>
<string name="assistant">AI assistant</string>
<string name="assistant_intro">Optional. Everything in this app works without it. Nothing is sent anywhere unless you set this up yourself, and no account or key ships with the app.</string>
<string name="assistant_mode">How to reach a model</string>
<string name="assistant_off">Off</string>
<string name="assistant_off_note">No assistant. Ganjoor&#8217;s own Persian summaries are still shown if you enable them in reading settings.</string>
<string name="assistant_server">Your own server</string>
<string name="assistant_server_note">Talks to anything speaking the OpenAI chat API — Ollama, LM Studio, llama.cpp, LocalAI — and to Claude, which uses its own.</string>
<string name="assistant_share">Send to an app</string>
<string name="assistant_share_note">Hands the text to any assistant installed on your phone. Nothing leaves this app until you pick one.</string>
<string name="assistant_language">Answer in</string>
<string name="assistant_presets">Presets</string>
<string name="assistant_base_url">Base URL</string>
<string name="assistant_base_url_note">Ollama: http://127.0.0.1:11434/v1 · LM Studio: http://127.0.0.1:1234/v1 · OpenAI: https://api.openai.com/v1 · DeepSeek: https://api.deepseek.com/v1 · Gemini: https://generativelanguage.googleapis.com/v1beta/openai · Claude: https://api.anthropic.com/v1</string>
<string name="assistant_model">Model</string>
<string name="assistant_api_key">API key (optional)</string>
<string name="assistant_api_key_note">A server on your own machine usually needs none. Stored unencrypted on this device.</string>
<string name="assistant_test">Test connection</string>
<string name="assistant_testing">Testing…</string>
<string name="assistant_privacy">When you ask for a translation or a summary, only the verse or poem you chose is sent to the endpoint you configured.</string>
<string name="assistant_translate">Translate</string>
<string name="assistant_summarise">Summarise</string>
<string name="assistant_explain">Explain this couplet</string>
<string name="assistant_translate_summary">Translate Ganjoor&#8217;s summary</string>
<string name="assistant_not_set_up">Set up an assistant in settings to use this.</string>
<string name="assistant_working">Asking…</string>
<string name="look_up">Look up</string>
<string name="share">Share</string>
<string name="share_poem">Share this poem</string>
<string name="selection_actions">What would you like to do?</string>
<string name="assistant_ask">Ask an assistant</string>
<string name="assistant_ask_prompt">Translate this Persian poetry and explain what it means:</string>
<string name="assistant_title">AI assistant</string>
<string name="assistant_translate_prompt">Translate this:</string>
<string name="this_couplet">This couplet</string>
<string name="couplet_options">Options for this couplet</string>
<string name="sort_pinned">Pinned</string>
<string name="pin_poet">Pin to the top</string>
<string name="unpin_poet">Unpin</string>
<string name="pin_hint">Press and hold a poet to keep them here</string>
<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>
+1
View File
@@ -9,4 +9,5 @@
<style name="Theme.Ganjoorappandroid" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:windowBackground">@color/window_background</item>
</style>
</resources>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
A language model the reader runs themselves — Ollama, LM Studio, llama.cpp — listens on plain
HTTP, usually on their own machine over the local network. Android's config can name domains
but not address ranges, so there is no way to permit exactly "the reader's own server" and
nothing else; cleartext is allowed and the choice of endpoint is left to them.
Everything the app fetches on its own account — the poems, the poet portraits, the recitations
— is HTTPS regardless, and none of it goes through this.
-->
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
</network-security-config>
@@ -0,0 +1,66 @@
package com.ganjoor.android
import com.ganjoor.android.data.Assistant
import com.ganjoor.android.data.AssistantLanguage
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.IOException
class AssistantTest {
@Test
fun `recognises anthropic by its host`() {
assertTrue(Assistant.isClaude("https://api.anthropic.com/v1"))
assertFalse(Assistant.isClaude("https://api.openai.com/v1"))
assertFalse(Assistant.isClaude("http://127.0.0.1:11434/v1"))
}
@Test
fun `reads an openai reply`() {
val body = """{"choices":[{"message":{"role":"assistant","content":" the rose "}}]}"""
assertEquals("the rose", Assistant.reply(body, claude = false))
}
/** Anthropic answers with content blocks, and the first is not always the text one. */
@Test
fun `reads a claude reply past a non-text block`() {
val body = """{"content":[{"type":"thinking","text":"x"},{"type":"text","text":" the rose "}]}"""
assertEquals("the rose", Assistant.reply(body, claude = true))
}
@Test
fun `the cache keeps answers apart by prompt, language and text`() {
val a = Assistant.cacheKey("translate", AssistantLanguage.Urdu, "x")
assertEquals(a, Assistant.cacheKey("translate", AssistantLanguage.Urdu, "x"))
assertNotEquals(a, Assistant.cacheKey("explain", AssistantLanguage.Urdu, "x"))
assertNotEquals(a, Assistant.cacheKey("translate", AssistantLanguage.English, "x"))
assertNotEquals(a, Assistant.cacheKey("translate", AssistantLanguage.Urdu, "y"))
}
/** Scrolling a long poem must not re-ask for something already answered. */
@Test
fun `a remembered answer comes back`() {
val key = Assistant.cacheKey("summary", AssistantLanguage.Urdu, "remembered")
assertNull(Assistant.cached(key))
Assistant.remember(key, "the answer")
assertEquals("the answer", Assistant.cached(key))
}
@Test
fun `the cache does not grow without bound`() {
repeat(40) { Assistant.remember("bulk-$it", "reply $it") }
assertNull(Assistant.cached("bulk-0"))
assertEquals("reply 39", Assistant.cached("bulk-39"))
}
@Test
fun `an empty reply is an error, not an empty answer`() {
assertThrows(IOException::class.java) { Assistant.reply("""{"choices":[]}""", false) }
assertThrows(IOException::class.java) { Assistant.reply("""{"content":[]}""", true) }
}
}
@@ -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))
}
}
@@ -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))
}
}
@@ -0,0 +1,39 @@
package com.ganjoor.android
import com.ganjoor.android.data.PoetRef
import com.ganjoor.android.ui.pinnedFirst
import org.junit.Assert.assertEquals
import org.junit.Test
class PinnedPoetsTest {
private fun poets(vararg slugs: String) =
slugs.mapIndexed { i, slug -> PoetRef(id = i, nickname = slug, fullUrl = "/$slug") }
private val all = poets("hafez", "moulavi", "saadi", "ferdousi")
/** The default view must never be empty, which is the whole reason it can be the default. */
@Test
fun `no pins leaves ganjoor's order untouched`() {
assertEquals(all, pinnedFirst(all, emptyList()))
}
@Test
fun `pinned poets come first, in the order they were pinned`() {
val order = pinnedFirst(all, listOf("/saadi", "/hafez")).map { it.nickname }
assertEquals(listOf("saadi", "hafez", "moulavi", "ferdousi"), order)
}
/** A pin for a poet who isn't in the list — filtered out by a search — must not crash. */
@Test
fun `a pin with no matching poet is ignored`() {
val order = pinnedFirst(poets("hafez"), listOf("/saadi", "/hafez")).map { it.nickname }
assertEquals(listOf("hafez"), order)
}
@Test
fun `the unpinned keep their relative order`() {
val order = pinnedFirst(all, listOf("/ferdousi")).map { it.nickname }
assertEquals(listOf("ferdousi", "hafez", "moulavi", "saadi"), order)
}
}
@@ -0,0 +1,25 @@
package com.ganjoor.android
import com.ganjoor.android.ui.webUrl
import org.junit.Assert.assertEquals
import org.junit.Test
class ShareTest {
@Test
fun `builds a ganjoor link from a stored path`() {
assertEquals("https://ganjoor.net/hafez/ghazal/sh1", webUrl("/hafez/ghazal/sh1"))
}
/** Paths arrive with a leading slash and sometimes a trailing one; neither may double up. */
@Test
fun `trims slashes rather than doubling them`() {
assertEquals("https://ganjoor.net/saadi/golestan", webUrl("/saadi/golestan/"))
assertEquals("https://ganjoor.net/saadi/golestan", webUrl("saadi/golestan"))
}
@Test
fun `leaves an absolute url alone`() {
assertEquals("https://ganjoor.net/x", webUrl("https://ganjoor.net/x"))
}
}
+63
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

@@ -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

@@ -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.
@@ -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
View 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`.
@@ -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
View 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
View 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
View 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
View 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>
@@ -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.
@@ -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
View 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.
@@ -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>
@@ -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.
@@ -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
View 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
View 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
View 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
View 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>
@@ -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.
@@ -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
View 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.
@@ -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>
@@ -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.
@@ -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
View 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
View 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
View 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
View 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."
}
]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

@@ -0,0 +1,8 @@
Share a poem, a couplet, or any selected text, with a link back to ganjoor.net.
Look up a word from the text-selection menu, in this app or any other.
An optional AI assistant, off by default: connect your own Ollama, LM Studio,
llama.cpp or LocalAI server, or a service such as OpenAI, DeepSeek, Gemini or
Claude, to translate Ganjoor's summaries and explain a couplet. Nothing is sent
anywhere until you set one up, and no key ships with the app.
@@ -0,0 +1,8 @@
An assistant's translation now appears under the summary it translates, in the
page, instead of over it. Ganjoor writes a summary under each couplet as well
as under the poem, and both can be translated.
A couplet's save, copy, share and explain are reachable again: a chevron at the
end of every couplet opens them, and they also sit at the foot of the word
lookup. They used to need a tap that landed between words, which on a line of
poetry almost never happens.
@@ -0,0 +1,2 @@
Credits the Material Symbols glyphs the app draws its share, lookup and
assistant icons from, and states the terms around the optional AI assistant.
@@ -0,0 +1,11 @@
Keep the poets you read on the home screen. Press and hold any poet to pin
them; pinned poets sit first, in the order you pinned them.
The home screen now opens on that shelf, with Ganjoor's own order and an
alphabetical listing a tap away. Until you pin anything the shelf is simply
Ganjoor's order, so there is never an empty screen.
Poets can be shown as cards or as a list, whichever reads better.
Each poet in the list view carries a download button, and a green tick once their
poems are on the device. Text only — recitations are still streamed when you play them.
@@ -0,0 +1,8 @@
هم‌رسانی شعر، بیت، یا هر متنی که انتخاب کرده‌اید، همراه با پیوند به گنجور.
جست‌وجوی معنی واژه از منوی انتخاب متن، در این برنامه و در هر برنامهٔ دیگری.
دستیار هوش مصنوعی، به‌صورت اختیاری و به‌طور پیش‌فرض خاموش: سرور خودتان
(Ollama، LM Studio، llama.cpp، LocalAI) یا سرویسی مانند OpenAI، DeepSeek،
Gemini یا Claude را وصل کنید تا خلاصه‌های گنجور را ترجمه و بیت‌ها را شرح دهد.
تا خودتان تنظیم نکنید چیزی به جایی فرستاده نمی‌شود و هیچ کلیدی همراه برنامه نیست.
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff. Show more