Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e284872eaa | ||
|
|
50bbe0b729 | ||
|
|
5d12a1c29c | ||
|
|
16bcd6949f | ||
|
|
b4a8efe9d4 | ||
|
|
929aee2a02 | ||
|
|
a5cc0421b6 | ||
|
|
c9269713d1 | ||
|
|
d7568d1f49 | ||
|
|
e3cad61e9e | ||
|
|
ec05989f7d | ||
|
|
5daebcd87a | ||
|
|
d92445f21e | ||
|
|
2f9cb0e211 | ||
|
|
d320137063 | ||
|
|
90b527522b | ||
|
|
2f16b52c2c | ||
|
|
65bc9ed0fc | ||
|
|
576a07ae31 | ||
|
|
3d4f7aae3a | ||
|
|
9445f44759 |
No files matched your search
@@ -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.
|
||||
@@ -164,6 +171,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 +237,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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -42,6 +42,9 @@ object DownloadsRoute
|
||||
@Serializable
|
||||
object AboutRoute
|
||||
|
||||
@Serializable
|
||||
object AssistantRoute
|
||||
|
||||
/**
|
||||
* Opens a page with the poet list as the only thing beneath it.
|
||||
*
|
||||
@@ -75,6 +78,10 @@ fun GanjoorApp() {
|
||||
settingsOpen = false
|
||||
nav.navigate(AboutRoute)
|
||||
},
|
||||
LocalOpenAssistant provides {
|
||||
settingsOpen = false
|
||||
nav.navigate(AssistantRoute)
|
||||
},
|
||||
) {
|
||||
NavHost(
|
||||
navController = nav,
|
||||
@@ -144,6 +151,9 @@ 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() })
|
||||
}
|
||||
|
||||
@@ -4,22 +4,33 @@ import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.clickable
|
||||
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.FlowRow
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
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.Close
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.FavoriteBorder
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.VerticalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
@@ -34,11 +45,13 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.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
|
||||
@@ -50,7 +63,9 @@ import com.ganjoor.android.data.Bookmark
|
||||
import com.ganjoor.android.data.breadcrumbs
|
||||
import com.ganjoor.android.data.wordAt
|
||||
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
|
||||
@@ -67,7 +82,9 @@ fun PoemScreen(
|
||||
) {
|
||||
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<Pair<String, Bookmark>?>(null) }
|
||||
|
||||
Load(key = fullUrl, block = { Ganjoor.poem(fullUrl) }) { poem ->
|
||||
val prefs = LocalSettings.current.value
|
||||
@@ -95,6 +112,7 @@ fun PoemScreen(
|
||||
},
|
||||
actions = {
|
||||
HomeAction(onHome)
|
||||
ShareAction(poem = poem, couplets = couplets)
|
||||
BookmarkAction(
|
||||
url = fullUrl,
|
||||
title = poem.title,
|
||||
@@ -105,79 +123,188 @@ fun PoemScreen(
|
||||
)
|
||||
}
|
||||
) { insets ->
|
||||
// Free-form selection for copying any span; tapping a word looks it up, and the
|
||||
// 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 {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(
|
||||
start = 20.dp,
|
||||
end = 20.dp,
|
||||
top = insets.calculateTopPadding() + 8.dp,
|
||||
bottom = insets.calculateBottomPadding() + 32.dp,
|
||||
),
|
||||
) {
|
||||
item {
|
||||
Column(Modifier.padding(bottom = 12.dp)) {
|
||||
Breadcrumbs(poem.fullTitle, poem.fullUrl.ifBlank { fullUrl }, onCategory)
|
||||
RecitationPlayer(poem.id)
|
||||
poem.metre?.rhythm?.let { rhythm ->
|
||||
Text(
|
||||
text = rhythm,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
// Narrow and wide are the same reader in different containers. On a phone the poem
|
||||
// has the window to itself and the dictionary arrives as a sheet over it; given the
|
||||
// width of a tablet or an opened foldable, the poem's place in the poet's work sits
|
||||
// on one side of it and the dictionary on the other, and nothing covers the verse.
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
val wide = maxWidth >= WideScreen
|
||||
|
||||
val body: @Composable (Modifier) -> Unit = { modifier ->
|
||||
// Free-form selection for copying any span; tapping a word looks it up, and
|
||||
// the per-couplet actions save a passage with the reference attached, which a
|
||||
// raw copy would lose.
|
||||
//
|
||||
// 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.
|
||||
SelectionContainer(modifier) {
|
||||
PoemBody(
|
||||
poem = poem,
|
||||
fullUrl = fullUrl,
|
||||
couplets = couplets,
|
||||
style = style,
|
||||
showSummaries = prefs.showSummaries,
|
||||
siblings = siblings,
|
||||
here = here,
|
||||
insets = insets,
|
||||
onPoem = onPoem,
|
||||
onCategory = onCategory,
|
||||
onWord = { word, passage -> tapped = word to passage },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (wide) {
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
// First child, so in this right-to-left layout it lands on the right.
|
||||
PoemTree(
|
||||
poemUrl = fullUrl,
|
||||
onPoem = onPoem,
|
||||
contentPadding = insets,
|
||||
modifier = Modifier.width(300.dp).fillMaxHeight(),
|
||||
)
|
||||
VerticalDivider()
|
||||
body(Modifier.weight(1f))
|
||||
tapped?.let { (word, passage) ->
|
||||
VerticalDivider()
|
||||
DictionaryPane(
|
||||
word = word,
|
||||
passage = passage,
|
||||
onClose = { tapped = null },
|
||||
insets = insets,
|
||||
modifier = Modifier.width(360.dp).fillMaxHeight(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items(couplets) { couplet ->
|
||||
Couplet(
|
||||
couplet = couplet,
|
||||
style = style,
|
||||
showSummaries = prefs.showSummaries,
|
||||
source = Bookmark(fullUrl, poem.title, poem.fullTitle),
|
||||
onWord = { tappedWord = it },
|
||||
)
|
||||
}
|
||||
|
||||
if (prefs.showSummaries) {
|
||||
poem.poemSummary?.takeIf { it.isNotBlank() }?.let { summary ->
|
||||
item { PoemSummary(summary) }
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
SiblingNav(
|
||||
previous = if (here > 0) siblings[here - 1] else null,
|
||||
next = if (here >= 0) siblings.getOrNull(here + 1) else null,
|
||||
onPoem = onPoem,
|
||||
)
|
||||
}
|
||||
|
||||
poem.sourceName?.takeIf { it.isNotBlank() }?.let { source ->
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.source, source),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 16.dp),
|
||||
} else {
|
||||
body(Modifier)
|
||||
tapped?.let { (word, passage) ->
|
||||
WordSheet(
|
||||
word = word,
|
||||
passage = passage,
|
||||
onDismiss = { tapped = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The poem itself, held to a reading measure. Left to fill a tablet, a couplet's two hemistichs
|
||||
* are flung to opposite edges of the window with a hand's width of nothing between them, and the
|
||||
* eye has to cross the whole screen between halves of one line.
|
||||
*/
|
||||
@Composable
|
||||
private fun PoemBody(
|
||||
poem: Poem,
|
||||
fullUrl: String,
|
||||
couplets: List<List<Verse>>,
|
||||
style: androidx.compose.ui.text.TextStyle,
|
||||
showSummaries: Boolean,
|
||||
siblings: List<PoemRef>,
|
||||
here: Int,
|
||||
insets: PaddingValues,
|
||||
onPoem: (String) -> Unit,
|
||||
onCategory: (String) -> Unit,
|
||||
onWord: (String, Bookmark) -> Unit,
|
||||
) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.widthIn(max = ReadingMeasure).fillMaxSize(),
|
||||
contentPadding = PaddingValues(
|
||||
start = 20.dp,
|
||||
end = 20.dp,
|
||||
top = insets.calculateTopPadding() + 8.dp,
|
||||
bottom = insets.calculateBottomPadding() + 32.dp,
|
||||
),
|
||||
) {
|
||||
item {
|
||||
Column(Modifier.padding(bottom = 12.dp)) {
|
||||
Breadcrumbs(poem.fullTitle, poem.fullUrl.ifBlank { fullUrl }, onCategory)
|
||||
RecitationPlayer(poem.id)
|
||||
poem.metre?.rhythm?.let { rhythm ->
|
||||
Text(
|
||||
text = rhythm,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items(couplets) { couplet ->
|
||||
Couplet(
|
||||
couplet = couplet,
|
||||
style = style,
|
||||
showSummaries = showSummaries,
|
||||
source = Bookmark(fullUrl, poem.title, poem.fullTitle),
|
||||
onWord = onWord,
|
||||
)
|
||||
}
|
||||
|
||||
if (showSummaries) {
|
||||
poem.poemSummary?.takeIf { it.isNotBlank() }?.let { summary ->
|
||||
item { PoemSummary(summary) }
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
SiblingNav(
|
||||
previous = if (here > 0) siblings[here - 1] else null,
|
||||
next = if (here >= 0) siblings.getOrNull(here + 1) else null,
|
||||
onPoem = onPoem,
|
||||
)
|
||||
}
|
||||
|
||||
poem.sourceName?.takeIf { it.isNotBlank() }?.let { source ->
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.source, source),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tappedWord?.let { word ->
|
||||
WordSheet(word = word, onDismiss = { tappedWord = null })
|
||||
/**
|
||||
* The dictionary as a column beside the poem rather than a sheet over it, so the line the word
|
||||
* came from stays in view while its meaning is read. Closeable, because it holds its word until
|
||||
* dismissed — unlike the sheet, which a tap outside takes away.
|
||||
*/
|
||||
@Composable
|
||||
private fun DictionaryPane(
|
||||
word: String,
|
||||
passage: Bookmark,
|
||||
onClose: () -> Unit,
|
||||
insets: PaddingValues,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier.padding(top = insets.calculateTopPadding())) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 20.dp, end = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.dictionary),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(Icons.Default.Close, stringResource(R.string.close))
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
WordLookup(word) { PassageFooter(passage) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,6 +338,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,17 +384,18 @@ private fun Couplet(
|
||||
style: androidx.compose.ui.text.TextStyle,
|
||||
showSummaries: Boolean,
|
||||
source: Bookmark,
|
||||
onWord: (String) -> Unit,
|
||||
onWord: (String, Bookmark) -> Unit,
|
||||
) {
|
||||
// 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,
|
||||
onWord = { onWord(it, passage) },
|
||||
// A tap that lands between words still opens the couplet's own actions.
|
||||
onElsewhere = { actionsOpen = !actionsOpen },
|
||||
)
|
||||
@@ -259,12 +408,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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -332,9 +518,10 @@ internal fun wordTappedAt(
|
||||
|
||||
/** 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 +539,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 +573,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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
package com.ganjoor.android.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
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.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.ganjoor.android.R
|
||||
import com.ganjoor.android.data.Category
|
||||
import com.ganjoor.android.data.Ganjoor
|
||||
|
||||
/**
|
||||
* Below this, the window is a phone: one column, and the dictionary arrives as a sheet over the
|
||||
* poem. At or above it there is room to put the poem's place in the poet's work beside it, and
|
||||
* the dictionary beside that, without squeezing the measure the poetry is set to.
|
||||
*
|
||||
* 840dp is Material's "expanded" width — a tablet in landscape, or a foldable opened out.
|
||||
*/
|
||||
val WideScreen = 840.dp
|
||||
|
||||
/** The reading measure. A line of verse set across a whole tablet is a line nobody can follow. */
|
||||
val ReadingMeasure = 680.dp
|
||||
|
||||
/**
|
||||
* One row of the contents tree. [expanded] is null for a poem, which has nothing to open;
|
||||
* [url] is null for the placeholder shown while a branch is still loading.
|
||||
*/
|
||||
private data class TreeRow(
|
||||
val label: String,
|
||||
val url: String?,
|
||||
val depth: Int,
|
||||
val expanded: Boolean?,
|
||||
val current: Boolean = false,
|
||||
)
|
||||
|
||||
/** Every category above [poemUrl]: `/hafez/ghazal/sh1` -> `/hafez`, `/hafez/ghazal`. */
|
||||
private fun ancestorUrls(poemUrl: String): List<String> {
|
||||
val segments = poemUrl.trim('/').split('/').filter { it.isNotEmpty() }
|
||||
if (segments.size <= 1) return emptyList()
|
||||
return (1 until segments.size).map { "/" + segments.take(it).joinToString("/") }
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens the loaded part of the tree into rows. A branch that is open but not yet fetched
|
||||
* contributes a spinner rather than nothing, so an expanding node never looks broken.
|
||||
*/
|
||||
private fun buildRows(
|
||||
root: String,
|
||||
loaded: Map<String, Category>,
|
||||
expanded: Set<String>,
|
||||
current: String,
|
||||
): List<TreeRow> {
|
||||
val rows = mutableListOf<TreeRow>()
|
||||
|
||||
fun walk(url: String, depth: Int) {
|
||||
val category = loaded[url]
|
||||
if (category == null) {
|
||||
rows += TreeRow(label = "", url = null, depth = depth, expanded = null)
|
||||
return
|
||||
}
|
||||
category.childCats.forEach { child ->
|
||||
val open = child.fullUrl in expanded
|
||||
rows += TreeRow(child.title, child.fullUrl, depth, open)
|
||||
if (open) walk(child.fullUrl, depth + 1)
|
||||
}
|
||||
category.poems.forEach { poem ->
|
||||
rows += TreeRow(poem.title, poem.fullUrl, depth, null, poem.fullUrl == current)
|
||||
}
|
||||
}
|
||||
|
||||
walk(root, 0)
|
||||
return rows
|
||||
}
|
||||
|
||||
/**
|
||||
* Where this poem sits in its poet's work — poet » book » section » poem — as a tree beside the
|
||||
* reader, with the path to the open poem already unfolded and scrolled to.
|
||||
*
|
||||
* Tapping a category folds it open or shut rather than navigating: a section's own page lists the
|
||||
* same poems this tree already shows, so opening it would only cost the reader their place. The
|
||||
* breadcrumbs above the poem are the way out to a category page.
|
||||
*
|
||||
* ponytail: branches are fetched as they open and kept only while the screen lives. The disk
|
||||
* cache makes re-opening cheap, and a poet's whole tree is far too much to hold eagerly.
|
||||
*/
|
||||
@Composable
|
||||
fun PoemTree(
|
||||
poemUrl: String,
|
||||
onPoem: (String) -> Unit,
|
||||
contentPadding: PaddingValues,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val ancestors = remember(poemUrl) { ancestorUrls(poemUrl) }
|
||||
if (ancestors.isEmpty()) return
|
||||
val root = ancestors.first()
|
||||
|
||||
val loaded = remember(poemUrl) { mutableStateMapOf<String, Category>() }
|
||||
var expanded by remember(poemUrl) { mutableStateOf(ancestors.toSet()) }
|
||||
var unreachable by remember(poemUrl) { mutableStateOf(emptySet<String>()) }
|
||||
|
||||
LaunchedEffect(poemUrl, expanded) {
|
||||
expanded.filterNot { it in loaded || it in unreachable }.forEach { url ->
|
||||
runCatching { Ganjoor.category(url) }
|
||||
.onSuccess { loaded[url] = it }
|
||||
// A branch that will not load stays shut rather than retrying on every
|
||||
// recomposition; the poem itself is already on screen and still readable.
|
||||
.onFailure { unreachable = unreachable + url }
|
||||
}
|
||||
}
|
||||
|
||||
val rows = remember(loaded.size, expanded, poemUrl) {
|
||||
buildRows(root, loaded, expanded, poemUrl)
|
||||
}
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
var settled by remember(poemUrl) { mutableStateOf(false) }
|
||||
// Once, when the open poem's row first exists: a book of 495 ghazals opens far from the one
|
||||
// being read, and a tree that starts at ghazal 1 is a tree nobody scrolls.
|
||||
LaunchedEffect(rows) {
|
||||
if (settled) return@LaunchedEffect
|
||||
val index = rows.indexOfFirst { it.current }
|
||||
if (index >= 0) {
|
||||
listState.scrollToItem(index + 1)
|
||||
settled = true
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(modifier = modifier, state = listState, contentPadding = contentPadding) {
|
||||
item {
|
||||
Text(
|
||||
text = loaded[root]?.title ?: stringResource(R.string.contents),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 12.dp, bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
items(rows) { row ->
|
||||
TreeRowItem(
|
||||
row = row,
|
||||
onToggle = {
|
||||
expanded = if (row.url in expanded) expanded - row.url!! else expanded + row.url!!
|
||||
},
|
||||
onPoem = onPoem,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TreeRowItem(row: TreeRow, onToggle: () -> Unit, onPoem: (String) -> Unit) {
|
||||
// Indent by depth, on top of the row's own padding, so nesting reads at a glance.
|
||||
val indent = (row.depth * 14).dp
|
||||
|
||||
if (row.url == null) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().padding(start = indent + 12.dp, top = 8.dp, bottom = 8.dp),
|
||||
) {
|
||||
CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val isCategory = row.expanded != null
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { if (isCategory) onToggle() else onPoem(row.url) }
|
||||
.padding(start = indent + 12.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
if (isCategory) {
|
||||
Icon(
|
||||
// Down when open, and pointing along the text when shut — the chevron is
|
||||
// auto-mirrored, so it turns the right way in a right-to-left layout.
|
||||
imageVector = if (row.expanded == true) Icons.Default.KeyboardArrowDown
|
||||
else Icons.AutoMirrored.Filled.KeyboardArrowLeft,
|
||||
contentDescription = stringResource(
|
||||
if (row.expanded == true) R.string.collapse else R.string.expand
|
||||
),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = row.label,
|
||||
style = if (isCategory) MaterialTheme.typography.bodyMedium
|
||||
else MaterialTheme.typography.bodySmall,
|
||||
fontWeight = if (row.current) FontWeight.Bold else null,
|
||||
color = when {
|
||||
row.current -> MaterialTheme.colorScheme.primary
|
||||
isCategory -> MaterialTheme.colorScheme.onSurface
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
@@ -92,39 +102,39 @@ fun PoetsScreen(
|
||||
Load(key = Unit, block = { Ganjoor.manifest() }) { 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
|
||||
when (sort) {
|
||||
// Persian letters don't sort correctly by code point (آ vs ا, ی vs ي), so hand
|
||||
// the ordering to a collator rather than String.compareTo.
|
||||
PoetSort.Name -> {
|
||||
val collator = Collator.getInstance(Locale.forLanguageTag("fa"))
|
||||
matches.sortedWith { a, b -> collator.compare(a.nickname, b.nickname) }
|
||||
}
|
||||
|
||||
// Pinned first, in the order they were pinned, then the rest untouched. With
|
||||
// nothing pinned this is Ganjoor's order, which is why it can be the default.
|
||||
PoetSort.Pinned -> pinnedFirst(matches, pinned.items)
|
||||
|
||||
PoetSort.Default -> matches
|
||||
}
|
||||
}
|
||||
|
||||
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 +143,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 +228,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 +360,31 @@ 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -44,6 +44,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
|
||||
@@ -137,11 +138,13 @@ 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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,8 @@ 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,
|
||||
)
|
||||
|
||||
/** Reading preferences, kept in SharedPreferences and read once at startup. */
|
||||
@@ -75,7 +82,8 @@ 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),
|
||||
)
|
||||
)
|
||||
private set
|
||||
@@ -94,6 +102,7 @@ class Settings(context: Context) {
|
||||
putBoolean("offline", p.offline)
|
||||
putBoolean("oled", p.oled)
|
||||
putString("poetSort", p.poetSort.name)
|
||||
putBoolean("poetGrid", p.poetGrid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('/')
|
||||
@@ -2,6 +2,7 @@ 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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
@@ -31,7 +32,9 @@ 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
|
||||
|
||||
@@ -43,7 +46,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 +65,56 @@ 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) { PassageFooter(passage) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The couplet the word was tapped in, with its own actions, under the definitions.
|
||||
*
|
||||
* Shared by the phone's sheet and the wide-screen dictionary pane, which show the same lookup in
|
||||
* two different containers.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ColumnScope.PassageFooter(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 +128,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 +230,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)
|
||||
@@ -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>
|
||||
@@ -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,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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
<string name="close">بستن</string>
|
||||
<string name="contents">فهرست</string>
|
||||
<string name="expand">گشودن</string>
|
||||
<string name="collapse">بستن شاخه</string>
|
||||
</resources>
|
||||
@@ -10,4 +10,5 @@
|
||||
<item name="android:windowBackground">@color/window_background</item>
|
||||
<item name="android:windowSplashScreenBackground">@color/window_background</item>
|
||||
</style>
|
||||
|
||||
</resources>
|
||||
@@ -4,4 +4,5 @@
|
||||
<style name="Theme.Ganjoorappandroid" parent="android:Theme.Material.NoActionBar">
|
||||
<item name="android:windowBackground">@color/window_background</item>
|
||||
</style>
|
||||
|
||||
</resources>
|
||||
@@ -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>
|
||||
<string name="close">بند کریں</string>
|
||||
<string name="contents">فہرست</string>
|
||||
<string name="expand">کھولیں</string>
|
||||
<string name="collapse">بند کریں</string>
|
||||
</resources>
|
||||
@@ -10,4 +10,5 @@
|
||||
<item name="android:windowBackground">@color/window_background</item>
|
||||
<item name="android:windowSplashScreenBackground">@color/window_background</item>
|
||||
</style>
|
||||
|
||||
</resources>
|
||||
@@ -64,6 +64,7 @@
|
||||
<string name="load_more">Load more</string>
|
||||
<string name="done">Done</string>
|
||||
<string name="about">About & 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’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’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>
|
||||
<string name="close">Close</string>
|
||||
<string name="contents">Contents</string>
|
||||
<string name="expand">Expand</string>
|
||||
<string name="collapse">Collapse</string>
|
||||
</resources>
|
||||
@@ -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,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"))
|
||||
}
|
||||
}
|
||||
@@ -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 را وصل کنید تا خلاصههای گنجور را ترجمه و بیتها را شرح دهد.
|
||||
تا خودتان تنظیم نکنید چیزی به جایی فرستاده نمیشود و هیچ کلیدی همراه برنامه نیست.
|
||||
@@ -0,0 +1,7 @@
|
||||
ترجمهٔ دستیار حالا زیر همان خلاصهای که ترجمه میکند، در خودِ صفحه نشان داده
|
||||
میشود، نه روی آن. گنجور هم زیر هر بیت خلاصه مینویسد و هم زیر شعر؛ هر دو را
|
||||
میتوان ترجمه کرد.
|
||||
|
||||
ذخیره، رونوشت، همرسانی و شرحِ هر بیت دوباره در دسترس است: نشانهٔ پیکان در
|
||||
انتهای هر بیت آنها را باز میکند و در پای جستوجوی واژه هم هست. پیش از این
|
||||
باید جایی میان دو واژه ضربه میزدید، که در یک مصراع شعر تقریباً هرگز رخ نمیدهد.
|
||||
@@ -0,0 +1,2 @@
|
||||
سپاسگزاری از Material Symbols که نشانههای همرسانی، جستوجو و دستیار از روی
|
||||
آن کشیده شدهاند، و روشنکردن شرایط مربوط به دستیار هوش مصنوعیِ اختیاری.
|
||||
@@ -0,0 +1,11 @@
|
||||
شاعرانی را که میخوانید روی صفحهٔ نخست نگه دارید. انگشت را روی هر شاعر نگه دارید
|
||||
تا برگزیده شود؛ برگزیدگان به همان ترتیبی که انتخاب کردهاید، نخست میآیند.
|
||||
|
||||
صفحهٔ نخست حالا با همین قفسه باز میشود و ترتیب خود گنجور و فهرست الفبایی یک
|
||||
ضربه فاصله دارند. تا چیزی برنگزیدهاید، قفسه همان ترتیب گنجور است، پس هرگز
|
||||
صفحهای خالی نمیبینید.
|
||||
|
||||
شاعران را میتوان کارتی یا فهرستی دید، هرکدام که خواناتر است.
|
||||
|
||||
هر شاعر در نمایش فهرستی یک دکمهٔ دریافت دارد، و پس از ذخیرهشدن شعرهایش یک تیک سبز.
|
||||
فقط متن؛ خوانشها همچنان هنگام پخش از اینترنت میآیند.
|
||||
@@ -0,0 +1,8 @@
|
||||
کلام، شعر، یا کوئی بھی منتخب متن شیئر کریں، گنجور کے لنک کے ساتھ۔
|
||||
|
||||
متن منتخب کرنے کے مینو سے لفظ کا مطلب دیکھیں — اس ایپ میں اور کسی بھی دوسری ایپ میں۔
|
||||
|
||||
مصنوعی ذہانت کا اختیاری معاون، جو پہلے سے بند ہے: اپنا سرور (Ollama، LM Studio،
|
||||
llama.cpp، LocalAI) یا OpenAI، DeepSeek، Gemini یا Claude جیسی سروس جوڑیں تاکہ
|
||||
گنجور کے خلاصوں کا ترجمہ ہو اور اشعار کی تشریح ہو۔ جب تک آپ خود ترتیب نہ دیں کچھ
|
||||
کہیں نہیں بھیجا جاتا، اور ایپ کے ساتھ کوئی کلید نہیں آتی۔
|
||||
@@ -0,0 +1,8 @@
|
||||
معاون کا ترجمہ اب اسی خلاصے کے نیچے، صفحے ہی میں دکھایا جاتا ہے جس کا ترجمہ ہو
|
||||
رہا ہے — اوپر کسی الگ پردے میں نہیں۔ گنجور ہر شعر کے نیچے بھی خلاصہ لکھتا ہے
|
||||
اور پورے کلام کے نیچے بھی؛ دونوں کا ترجمہ ہو سکتا ہے۔
|
||||
|
||||
ہر شعر کے محفوظ کرنے، نقل، شیئر اور تشریح کے بٹن دوبارہ قابلِ رسائی ہیں: ہر شعر
|
||||
کے آخر میں ایک نشان انہیں کھولتا ہے، اور وہ لفظ کی لغت کے نیچے بھی موجود ہیں۔
|
||||
پہلے ان کے لیے دو الفاظ کے درمیان ٹیپ کرنا پڑتا تھا، جو شعر کی سطر میں تقریباً
|
||||
کبھی نہیں ہوتا۔
|
||||
@@ -0,0 +1,2 @@
|
||||
Material Symbols کا اعتراف، جن سے شیئر، لغت اور معاون کے نشان لیے گئے ہیں، اور
|
||||
اختیاری مصنوعی ذہانت کے معاون سے متعلق شرائط کی وضاحت۔
|
||||
@@ -0,0 +1,12 @@
|
||||
جن شاعروں کو آپ پڑھتے ہیں انہیں صفحۂ اوّل پر رکھیں۔ کسی بھی شاعر پر دبا کر رکھیں
|
||||
تو وہ منتخب ہو جائے گا؛ منتخب شاعر اُسی ترتیب سے سب سے پہلے آتے ہیں جس ترتیب سے
|
||||
آپ نے انہیں رکھا۔
|
||||
|
||||
صفحۂ اوّل اب اسی فہرست سے کھلتا ہے، اور گنجور کی اپنی ترتیب اور حروفِ تہجی ایک
|
||||
ٹیپ کی دوری پر ہیں۔ جب تک آپ کچھ منتخب نہ کریں، یہ گنجور ہی کی ترتیب رہتی ہے،
|
||||
سو خالی صفحہ کبھی نہیں ملتا۔
|
||||
|
||||
شاعروں کو کارڈ کی صورت یا فہرست کی صورت، جو بہتر لگے، دیکھا جا سکتا ہے۔
|
||||
|
||||
فہرست کی صورت میں ہر شاعر کے ساتھ ڈاؤن لوڈ کا بٹن ہے، اور اُن کا کلام محفوظ ہو جانے پر
|
||||
سبز نشان۔ صرف متن؛ تلاوتیں اب بھی سنتے وقت آن لائن چلتی ہیں۔
|
||||
@@ -19,6 +19,22 @@ bundled rather than merely linked.
|
||||
|
||||
Nothing in Ganjoor's repositories restricts AI-assisted use.
|
||||
|
||||
## The AI assistant
|
||||
|
||||
Optional, off by default, and the app ships no vendor SDK, no API key and no default endpoint.
|
||||
Nothing is sent anywhere until a reader configures a server themselves.
|
||||
|
||||
| What | Terms |
|
||||
| --- | --- |
|
||||
| The client | Ours, MIT. It speaks the OpenAI chat-completions shape, which is a request format, not anyone's code, plus Anthropic's `/v1/messages` shape for Claude |
|
||||
| A self-hosted server (Ollama, LM Studio, llama.cpp, LocalAI) | Each is separately installed free software; this app only makes HTTP requests to it |
|
||||
| A hosted service (OpenAI, DeepSeek, Gemini, Claude) | Whatever the reader agreed with that service. Their terms and privacy policy govern what they do with the text sent |
|
||||
| What a model writes back | Generated text, not Ganjoor's and not ours. Shown as an aid, never stored next to the poem |
|
||||
|
||||
For F-Droid: nothing here is a non-free dependency, since no such code ships. A reader *may*
|
||||
point it at a non-free network service, which is why the setting is off by default and the
|
||||
privacy note sits on its own settings page.
|
||||
|
||||
## Dictionary
|
||||
|
||||
Five sources, each row in the database tagged with the one it came from so the app can name it
|
||||
@@ -67,6 +83,15 @@ Every dependency is Apache-2.0. The full text is in
|
||||
No Google Play Services, Firebase, analytics or tracking of any kind. The only Android
|
||||
permission requested is `INTERNET`.
|
||||
|
||||
## Icons
|
||||
|
||||
The share, lookup and assistant glyphs in `app/src/main/res/drawable/` are drawn from
|
||||
[Material Symbols](https://fonts.google.com/icons) path data, copied into our own vector files
|
||||
rather than pulled in as a dependency. Apache 2.0, the same terms as the Material icons listed
|
||||
under Libraries. The assistant glyph is Material's `auto_awesome`; the sparkle that has come to
|
||||
mean "ask a model" is used by Gemini and others, but their marks are trademarks and none is used
|
||||
here.
|
||||
|
||||
## Artwork
|
||||
|
||||
The launcher icon — an eight-point *shamsa* — is original work for this project, MIT like the
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Builds
|
||||
|
||||
Every release, kept so a version can be reinstalled without rebuilding it. All are signed with
|
||||
the same release key, so any of them upgrades any other in place without losing bookmarks.
|
||||
|
||||
Each is reproducible: check out the commit and run `./gradlew assembleRelease`. F-Droid builds
|
||||
from source and signs with its own key, so these are for direct installation only.
|
||||
|
||||
| Version | Code | Commit | What it added |
|
||||
| --- | --- | --- | --- |
|
||||
| 0.1.0 | 1 | `9445f44` | The base app: browsing, bookmarks, offline, themes, the bundled dictionary with pronunciation, and Ganjoor's chapter order. |
|
||||
| 0.2.0 | 2 | `d92445f` | Share a poem, couplet or selection; look a word up from any app's selection menu; the optional AI assistant. |
|
||||
| 0.2.1 | 3 | `ec05989` | Assistant answers in the page rather than over it, a visible chevron for each couplet's actions, and answers remembered so scrolling doesn't re-ask. |
|
||||
| 0.2.2 | 4 | `d7568d1` | Credits Material Symbols for the glyphs, and states the terms around the optional assistant. |
|
||||
| 0.3.0 | 5 | `HEAD` | Pin poets to the home screen: three views (pinned, Ganjoor's order, alphabetical), as cards or a list, with a download control on each row. |
|
||||
|
||||
## Checksums
|
||||
|
||||
```
|
||||
76121689da069d6ea2e2c6587d4b371ceb51d21bfe069a583d346d3da970337a ganjoor-0.1.0.apk
|
||||
43d78b773efca2b1aa2bf790c689b3086389d66c4ea0ffb72c9dba8bddb50770 ganjoor-0.2.0.apk
|
||||
5163554eb01845c131c31a9da7bdd748170bb9b5f82edaece754b4cf40b02b0c ganjoor-0.2.1.apk
|
||||
e473269044710813d2cf4119de663fe180f887239df7bdb1ccee0239ef84fd3e ganjoor-0.2.2.apk
|
||||
9c38d5cacf9e809c2189ba581f8aa6eb4072d4fa0316f00d66434461cf4b1459 ganjoor-0.3.0.apk
|
||||
```
|
||||
|
||||
Installing: `adb install -r releases/ganjoor-<version>.apk`. A phone holding a *debug* build has
|
||||
to have it uninstalled first — Android will not replace a debug signature with a release one.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in new issue
Block a user