Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7568d1f49 | ||
|
|
e3cad61e9e | ||
|
|
ec05989f7d | ||
|
|
5daebcd87a | ||
|
|
d92445f21e | ||
|
|
2f9cb0e211 |
No files matched your search
+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.2.2"
|
||||
}
|
||||
|
||||
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 = 4
|
||||
versionName = "0.2.2"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
@@ -166,6 +166,27 @@ object Assistant {
|
||||
.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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
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.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
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
|
||||
@@ -20,10 +22,10 @@ import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.setValue
|
||||
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
|
||||
@@ -36,57 +38,59 @@ import com.ganjoor.android.data.LocalAssistant
|
||||
import com.ganjoor.android.data.Prompts
|
||||
|
||||
/**
|
||||
* Asks the reader's own assistant and shows what comes back.
|
||||
* 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.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AssistantResultSheet(prompt: String, text: String, onDismiss: () -> Unit) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||
) {
|
||||
AssistantAnswer(text = text, prompt = prompt)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A button that asks the reader's own assistant, or hands the question to another app when no
|
||||
* server is configured. Both paths are one tap and neither requires any setting up, which is the
|
||||
* point: the feature is optional, so it cannot assume anyone switched it on.
|
||||
* 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 AssistantAction(prompt: String, text: String, label: Int, instruction: Int) {
|
||||
fun AssistantInline(prompt: String, text: String, label: Int, instruction: Int) {
|
||||
val assistant = LocalAssistant.current
|
||||
val context = LocalContext.current
|
||||
val ask = stringResource(instruction)
|
||||
var open by remember { mutableStateOf(false) }
|
||||
var asked by remember(text) { mutableStateOf(false) }
|
||||
|
||||
TextButton(onClick = {
|
||||
if (assistant.serverReady) open = 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))
|
||||
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)
|
||||
}
|
||||
}
|
||||
if (open) AssistantResultSheet(prompt = prompt, text = text, onDismiss = { open = false })
|
||||
}
|
||||
|
||||
/** The same answer as a plain screen, for the selection-menu activity. */
|
||||
/** The question and its answer, with nothing around them. */
|
||||
@Composable
|
||||
fun AssistantAnswer(text: String, prompt: String = "translate") {
|
||||
fun AssistantReply(prompt: String, text: String, modifier: Modifier = Modifier) {
|
||||
val settings = LocalAssistant.current
|
||||
val context = LocalContext.current
|
||||
val language = settings.language
|
||||
|
||||
val answer by produceState<Result<String>?>(null, prompt, text, language) {
|
||||
value = Assistant.ask(
|
||||
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) {
|
||||
@@ -97,14 +101,68 @@ fun AssistantAnswer(text: String, prompt: String = "translate") {
|
||||
else -> text
|
||||
},
|
||||
)
|
||||
result.getOrNull()?.let { Assistant.remember(key, it) }
|
||||
value = result
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.navigationBarsPadding()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp),
|
||||
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,
|
||||
@@ -112,34 +170,7 @@ fun AssistantAnswer(text: String, prompt: String = "translate") {
|
||||
maxLines = 4,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
|
||||
)
|
||||
val result = answer
|
||||
when {
|
||||
result == null -> Column {
|
||||
CircularProgressIndicator(modifier = Modifier.padding(bottom = 8.dp))
|
||||
Text(stringResource(R.string.assistant_working))
|
||||
}
|
||||
|
||||
result.isSuccess -> {
|
||||
// A reply in Urdu or English needs its own direction, not the reader's.
|
||||
val reply = result.getOrDefault("")
|
||||
InDirectionOf(reply) {
|
||||
Text(reply, style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
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),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
AssistantReply(prompt = prompt, text = text)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
@@ -53,6 +55,7 @@ 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
|
||||
@@ -71,7 +74,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
|
||||
@@ -148,7 +153,7 @@ fun PoemScreen(
|
||||
style = style,
|
||||
showSummaries = prefs.showSummaries,
|
||||
source = Bookmark(fullUrl, poem.title, poem.fullTitle),
|
||||
onWord = { tappedWord = it },
|
||||
onWord = { word, passage -> tapped = word to passage },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -181,8 +186,12 @@ fun PoemScreen(
|
||||
}
|
||||
}
|
||||
|
||||
tappedWord?.let { word ->
|
||||
WordSheet(word = word, onDismiss = { tappedWord = null })
|
||||
tapped?.let { (word, passage) ->
|
||||
WordSheet(
|
||||
word = word,
|
||||
passage = passage,
|
||||
onDismiss = { tapped = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,17 +271,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 },
|
||||
)
|
||||
@@ -285,12 +295,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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -358,7 +405,7 @@ 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
|
||||
@@ -395,12 +442,6 @@ private fun PassageActions(passage: Bookmark) {
|
||||
)
|
||||
Text(stringResource(R.string.share))
|
||||
}
|
||||
AssistantAction(
|
||||
prompt = "explain",
|
||||
text = passage.excerpt.orEmpty(),
|
||||
label = R.string.assistant_explain,
|
||||
instruction = R.string.assistant_ask_prompt,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -419,7 +460,7 @@ private fun PoemSummary(summary: String) {
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
AssistantAction(
|
||||
AssistantInline(
|
||||
prompt = "summary",
|
||||
text = summary,
|
||||
label = R.string.assistant_translate,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -62,11 +65,38 @@ private fun sourceLabel(source: String) = when (source) {
|
||||
else -> R.string.source_daneshjoo
|
||||
}
|
||||
|
||||
/** What the dictionary knows about a tapped word, as a sheet over the poem. */
|
||||
/**
|
||||
* 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) {
|
||||
ModalBottomSheet(onDismissRequest = onDismiss) { WordLookup(word) }
|
||||
fun WordSheet(word: String, onDismiss: () -> Unit, passage: Bookmark? = null) {
|
||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||
// Inside the lookup's own scroll, not after it: the lookup scrolls, so anything placed
|
||||
// below it is pushed past the bottom of the sheet with no way to reach it.
|
||||
WordLookup(word) {
|
||||
if (passage == null) return@WordLookup
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,7 +105,7 @@ fun WordSheet(word: String, onDismiss: () -> Unit) {
|
||||
* to sit over, and a scrim with no content under it is just a grey window.
|
||||
*/
|
||||
@Composable
|
||||
fun WordLookup(word: String) {
|
||||
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) }
|
||||
@@ -191,5 +221,8 @@ fun WordLookup(word: String) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
footer()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -105,4 +105,6 @@
|
||||
<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>
|
||||
</resources>
|
||||
@@ -105,4 +105,6 @@
|
||||
<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>
|
||||
</resources>
|
||||
@@ -110,4 +110,6 @@
|
||||
<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>
|
||||
</resources>
|
||||
@@ -1,8 +1,11 @@
|
||||
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
|
||||
@@ -30,6 +33,31 @@ class AssistantTest {
|
||||
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) }
|
||||
|
||||
@@ -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,8 @@
|
||||
همرسانی شعر، بیت، یا هر متنی که انتخاب کردهاید، همراه با پیوند به گنجور.
|
||||
|
||||
جستوجوی معنی واژه از منوی انتخاب متن، در این برنامه و در هر برنامهٔ دیگری.
|
||||
|
||||
دستیار هوش مصنوعی، بهصورت اختیاری و بهطور پیشفرض خاموش: سرور خودتان
|
||||
(Ollama، LM Studio، llama.cpp، LocalAI) یا سرویسی مانند OpenAI، DeepSeek،
|
||||
Gemini یا Claude را وصل کنید تا خلاصههای گنجور را ترجمه و بیتها را شرح دهد.
|
||||
تا خودتان تنظیم نکنید چیزی به جایی فرستاده نمیشود و هیچ کلیدی همراه برنامه نیست.
|
||||
@@ -0,0 +1,7 @@
|
||||
ترجمهٔ دستیار حالا زیر همان خلاصهای که ترجمه میکند، در خودِ صفحه نشان داده
|
||||
میشود، نه روی آن. گنجور هم زیر هر بیت خلاصه مینویسد و هم زیر شعر؛ هر دو را
|
||||
میتوان ترجمه کرد.
|
||||
|
||||
ذخیره، رونوشت، همرسانی و شرحِ هر بیت دوباره در دسترس است: نشانهٔ پیکان در
|
||||
انتهای هر بیت آنها را باز میکند و در پای جستوجوی واژه هم هست. پیش از این
|
||||
باید جایی میان دو واژه ضربه میزدید، که در یک مصراع شعر تقریباً هرگز رخ نمیدهد.
|
||||
@@ -0,0 +1,2 @@
|
||||
سپاسگزاری از Material Symbols که نشانههای همرسانی، جستوجو و دستیار از روی
|
||||
آن کشیده شدهاند، و روشنکردن شرایط مربوط به دستیار هوش مصنوعیِ اختیاری.
|
||||
@@ -0,0 +1,8 @@
|
||||
کلام، شعر، یا کوئی بھی منتخب متن شیئر کریں، گنجور کے لنک کے ساتھ۔
|
||||
|
||||
متن منتخب کرنے کے مینو سے لفظ کا مطلب دیکھیں — اس ایپ میں اور کسی بھی دوسری ایپ میں۔
|
||||
|
||||
مصنوعی ذہانت کا اختیاری معاون، جو پہلے سے بند ہے: اپنا سرور (Ollama، LM Studio،
|
||||
llama.cpp، LocalAI) یا OpenAI، DeepSeek، Gemini یا Claude جیسی سروس جوڑیں تاکہ
|
||||
گنجور کے خلاصوں کا ترجمہ ہو اور اشعار کی تشریح ہو۔ جب تک آپ خود ترتیب نہ دیں کچھ
|
||||
کہیں نہیں بھیجا جاتا، اور ایپ کے ساتھ کوئی کلید نہیں آتی۔
|
||||
@@ -0,0 +1,8 @@
|
||||
معاون کا ترجمہ اب اسی خلاصے کے نیچے، صفحے ہی میں دکھایا جاتا ہے جس کا ترجمہ ہو
|
||||
رہا ہے — اوپر کسی الگ پردے میں نہیں۔ گنجور ہر شعر کے نیچے بھی خلاصہ لکھتا ہے
|
||||
اور پورے کلام کے نیچے بھی؛ دونوں کا ترجمہ ہو سکتا ہے۔
|
||||
|
||||
ہر شعر کے محفوظ کرنے، نقل، شیئر اور تشریح کے بٹن دوبارہ قابلِ رسائی ہیں: ہر شعر
|
||||
کے آخر میں ایک نشان انہیں کھولتا ہے، اور وہ لفظ کی لغت کے نیچے بھی موجود ہیں۔
|
||||
پہلے ان کے لیے دو الفاظ کے درمیان ٹیپ کرنا پڑتا تھا، جو شعر کی سطر میں تقریباً
|
||||
کبھی نہیں ہوتا۔
|
||||
@@ -0,0 +1,2 @@
|
||||
Material Symbols کا اعتراف، جن سے شیئر، لغت اور معاون کے نشان لیے گئے ہیں، اور
|
||||
اختیاری مصنوعی ذہانت کے معاون سے متعلق شرائط کی وضاحت۔
|
||||
@@ -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,26 @@
|
||||
# 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 | `HEAD` | Credits Material Symbols for the glyphs, and states the terms around the optional assistant. |
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
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.
Reference in new issue
Block a user