Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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) }
|
if (file.exists()) file.inputStream().use { load(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A delivered file should say what it is without being opened.
|
||||||
|
base {
|
||||||
|
archivesName = "ganjoor-0.2.1"
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.ganjoor.android"
|
namespace = "com.ganjoor.android"
|
||||||
compileSdk {
|
compileSdk {
|
||||||
@@ -24,8 +29,11 @@ android {
|
|||||||
applicationId = "com.ganjoor.android"
|
applicationId = "com.ganjoor.android"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 37
|
targetSdk = 37
|
||||||
versionCode = 1
|
// Pre-1.0 while the app is still being shaped. versionCode only ever climbs: F-Droid
|
||||||
versionName = "0.1.0"
|
// refuses an update that does not, and one changelog file per code lives in
|
||||||
|
// fastlane/metadata/android/*/changelogs/.
|
||||||
|
versionCode = 3
|
||||||
|
versionName = "0.2.1"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,6 +166,27 @@ object Assistant {
|
|||||||
.build()
|
.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. */
|
/** Anthropic is the one service that does not speak the OpenAI shape. */
|
||||||
internal fun isClaude(baseUrl: String) = baseUrl.contains("anthropic.com", ignoreCase = true)
|
internal fun isClaude(baseUrl: String) = baseUrl.contains("anthropic.com", ignoreCase = true)
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
package com.ganjoor.android.ui
|
package com.ganjoor.android.ui
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Share
|
import androidx.compose.material.icons.filled.Share
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -20,10 +22,10 @@ import androidx.compose.material3.rememberModalBottomSheetState
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.produceState
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
@@ -36,57 +38,59 @@ import com.ganjoor.android.data.LocalAssistant
|
|||||||
import com.ganjoor.android.data.Prompts
|
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
|
* 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
|
* poem has no business being cached next to the poem itself. Sharing it on is one tap, for anyone
|
||||||
* who wants to keep it.
|
* 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
|
* The reply, in the page. A translation belongs under the thing it translates — leaving the poem
|
||||||
* server is configured. Both paths are one tap and neither requires any setting up, which is the
|
* to read it, whether to a sheet over the top or to another app entirely, breaks the reading.
|
||||||
* point: the feature is optional, so it cannot assume anyone switched it on.
|
*
|
||||||
|
* 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
|
@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 assistant = LocalAssistant.current
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val ask = stringResource(instruction)
|
val ask = stringResource(instruction)
|
||||||
var open by remember { mutableStateOf(false) }
|
var asked by remember(text) { mutableStateOf(false) }
|
||||||
|
|
||||||
TextButton(onClick = {
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
if (assistant.serverReady) open = true else context.shareText("$ask\n\n$text")
|
if (!asked) {
|
||||||
}) {
|
TextButton(onClick = {
|
||||||
Icon(
|
if (assistant.serverReady) asked = true else context.shareText("$ask\n\n$text")
|
||||||
painter = painterResource(R.drawable.ic_ask),
|
}) {
|
||||||
contentDescription = null,
|
Icon(
|
||||||
modifier = Modifier.size(18.dp).padding(end = 4.dp),
|
painter = painterResource(R.drawable.ic_ask),
|
||||||
)
|
contentDescription = null,
|
||||||
Text(stringResource(label))
|
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
|
@Composable
|
||||||
fun AssistantAnswer(text: String, prompt: String = "translate") {
|
fun AssistantReply(prompt: String, text: String, modifier: Modifier = Modifier) {
|
||||||
val settings = LocalAssistant.current
|
val settings = LocalAssistant.current
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val language = settings.language
|
val language = settings.language
|
||||||
|
|
||||||
val answer by produceState<Result<String>?>(null, prompt, text, language) {
|
val key = Assistant.cacheKey(prompt, language, text)
|
||||||
value = Assistant.ask(
|
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,
|
settings = settings,
|
||||||
system = Prompts.system(language),
|
system = Prompts.system(language),
|
||||||
user = when (prompt) {
|
user = when (prompt) {
|
||||||
@@ -97,14 +101,68 @@ fun AssistantAnswer(text: String, prompt: String = "translate") {
|
|||||||
else -> text
|
else -> text
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
result.getOrNull()?.let { Assistant.remember(key, it) }
|
||||||
|
value = result
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(
|
Column(modifier = modifier.fillMaxWidth().padding(top = 4.dp)) {
|
||||||
modifier = Modifier
|
when (val result = answer) {
|
||||||
.navigationBarsPadding()
|
null -> Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
.verticalScroll(rememberScrollState())
|
CircularProgressIndicator(
|
||||||
.padding(horizontal = 24.dp),
|
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 = text,
|
text = text,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
@@ -112,34 +170,7 @@ fun AssistantAnswer(text: String, prompt: String = "translate") {
|
|||||||
maxLines = 4,
|
maxLines = 4,
|
||||||
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
|
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
|
||||||
)
|
)
|
||||||
val result = answer
|
AssistantReply(prompt = prompt, text = text)
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -16,6 +16,8 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.material.icons.Icons
|
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.filled.Share
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Favorite
|
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.breadcrumbs
|
||||||
import com.ganjoor.android.data.wordAt
|
import com.ganjoor.android.data.wordAt
|
||||||
import com.ganjoor.android.data.Ganjoor
|
import com.ganjoor.android.data.Ganjoor
|
||||||
|
import com.ganjoor.android.data.LocalAssistant
|
||||||
import com.ganjoor.android.data.LocalBookmarks
|
import com.ganjoor.android.data.LocalBookmarks
|
||||||
import com.ganjoor.android.data.Poem
|
import com.ganjoor.android.data.Poem
|
||||||
import com.ganjoor.android.data.PoemRef
|
import com.ganjoor.android.data.PoemRef
|
||||||
@@ -71,7 +74,9 @@ fun PoemScreen(
|
|||||||
) {
|
) {
|
||||||
BackHandler(onBack = onUp)
|
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 ->
|
Load(key = fullUrl, block = { Ganjoor.poem(fullUrl) }) { poem ->
|
||||||
val prefs = LocalSettings.current.value
|
val prefs = LocalSettings.current.value
|
||||||
@@ -148,7 +153,7 @@ fun PoemScreen(
|
|||||||
style = style,
|
style = style,
|
||||||
showSummaries = prefs.showSummaries,
|
showSummaries = prefs.showSummaries,
|
||||||
source = Bookmark(fullUrl, poem.title, poem.fullTitle),
|
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 ->
|
tapped?.let { (word, passage) ->
|
||||||
WordSheet(word = word, onDismiss = { tappedWord = null })
|
WordSheet(
|
||||||
|
word = word,
|
||||||
|
passage = passage,
|
||||||
|
onDismiss = { tapped = null },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,17 +271,18 @@ private fun Couplet(
|
|||||||
style: androidx.compose.ui.text.TextStyle,
|
style: androidx.compose.ui.text.TextStyle,
|
||||||
showSummaries: Boolean,
|
showSummaries: Boolean,
|
||||||
source: Bookmark,
|
source: Bookmark,
|
||||||
onWord: (String) -> Unit,
|
onWord: (String, Bookmark) -> Unit,
|
||||||
) {
|
) {
|
||||||
// Tap, not long-press: long-press belongs to the text selection this sits inside.
|
// Tap, not long-press: long-press belongs to the text selection this sits inside.
|
||||||
var actionsOpen by remember(couplet) { mutableStateOf(false) }
|
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)) {
|
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||||
couplet.forEach { verse ->
|
couplet.forEach { verse ->
|
||||||
VerseText(
|
VerseText(
|
||||||
verse = verse,
|
verse = verse,
|
||||||
style = style,
|
style = style,
|
||||||
onWord = onWord,
|
onWord = { onWord(it, passage) },
|
||||||
// A tap that lands between words still opens the couplet's own actions.
|
// A tap that lands between words still opens the couplet's own actions.
|
||||||
onElsewhere = { actionsOpen = !actionsOpen },
|
onElsewhere = { actionsOpen = !actionsOpen },
|
||||||
)
|
)
|
||||||
@@ -285,12 +295,49 @@ private fun Couplet(
|
|||||||
text = summary,
|
text = summary,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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) {
|
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. */
|
/** Save this passage, or copy it. Saving keeps the link back to the poem; copying doesn't. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun PassageActions(passage: Bookmark) {
|
internal fun PassageActions(passage: Bookmark) {
|
||||||
val bookmarks = LocalBookmarks.current
|
val bookmarks = LocalBookmarks.current
|
||||||
val clipboard = LocalClipboardManager.current
|
val clipboard = LocalClipboardManager.current
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -395,12 +442,6 @@ private fun PassageActions(passage: Bookmark) {
|
|||||||
)
|
)
|
||||||
Text(stringResource(R.string.share))
|
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,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
AssistantAction(
|
AssistantInline(
|
||||||
prompt = "summary",
|
prompt = "summary",
|
||||||
text = summary,
|
text = summary,
|
||||||
label = R.string.assistant_translate,
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
@@ -31,7 +32,9 @@ import androidx.compose.ui.unit.LayoutDirection
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.ganjoor.android.R
|
import com.ganjoor.android.R
|
||||||
import com.ganjoor.android.data.Definition
|
import com.ganjoor.android.data.Definition
|
||||||
|
import com.ganjoor.android.data.Bookmark
|
||||||
import com.ganjoor.android.data.Dictionary
|
import com.ganjoor.android.data.Dictionary
|
||||||
|
import com.ganjoor.android.data.LocalAssistant
|
||||||
import com.ganjoor.android.data.Pronunciation
|
import com.ganjoor.android.data.Pronunciation
|
||||||
import com.ganjoor.android.ui.theme.readingStyle
|
import com.ganjoor.android.ui.theme.readingStyle
|
||||||
|
|
||||||
@@ -62,11 +65,38 @@ private fun sourceLabel(source: String) = when (source) {
|
|||||||
else -> R.string.source_daneshjoo
|
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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun WordSheet(word: String, onDismiss: () -> Unit) {
|
fun WordSheet(word: String, onDismiss: () -> Unit, passage: Bookmark? = null) {
|
||||||
ModalBottomSheet(onDismissRequest = onDismiss) { WordLookup(word) }
|
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.
|
* to sit over, and a scrim with no content under it is just a grey window.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun WordLookup(word: String) {
|
fun WordLookup(word: String, footer: @Composable ColumnScope.() -> Unit = {}) {
|
||||||
val prefs = LocalSettings.current.value
|
val prefs = LocalSettings.current.value
|
||||||
// A suggestion replaces what is being looked up, so the sheet can be followed like a trail.
|
// A suggestion replaces what is being looked up, so the sheet can be followed like a trail.
|
||||||
var current by remember(word) { mutableStateOf(word) }
|
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_ask_prompt">این شعر فارسی را ترجمه کن و معنایش را توضیح بده:</string>
|
||||||
<string name="assistant_title">هوش مصنوعی</string>
|
<string name="assistant_title">هوش مصنوعی</string>
|
||||||
<string name="assistant_translate_prompt">این متن را ترجمه کن:</string>
|
<string name="assistant_translate_prompt">این متن را ترجمه کن:</string>
|
||||||
|
<string name="this_couplet">این بیت</string>
|
||||||
|
<string name="couplet_options">گزینههای این بیت</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -105,4 +105,6 @@
|
|||||||
<string name="assistant_ask_prompt">اس فارسی کلام کا ترجمہ کریں اور اس کا مطلب بیان کریں:</string>
|
<string name="assistant_ask_prompt">اس فارسی کلام کا ترجمہ کریں اور اس کا مطلب بیان کریں:</string>
|
||||||
<string name="assistant_title">مصنوعی ذہانت</string>
|
<string name="assistant_title">مصنوعی ذہانت</string>
|
||||||
<string name="assistant_translate_prompt">اس کا ترجمہ کریں:</string>
|
<string name="assistant_translate_prompt">اس کا ترجمہ کریں:</string>
|
||||||
|
<string name="this_couplet">یہ شعر</string>
|
||||||
|
<string name="couplet_options">اس شعر کے اختیارات</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -110,4 +110,6 @@
|
|||||||
<string name="assistant_ask_prompt">Translate this Persian poetry and explain what it means:</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_title">AI assistant</string>
|
||||||
<string name="assistant_translate_prompt">Translate this:</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>
|
</resources>
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
package com.ganjoor.android
|
package com.ganjoor.android
|
||||||
|
|
||||||
import com.ganjoor.android.data.Assistant
|
import com.ganjoor.android.data.Assistant
|
||||||
|
import com.ganjoor.android.data.AssistantLanguage
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Assert.assertThrows
|
import org.junit.Assert.assertThrows
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -30,6 +33,31 @@ class AssistantTest {
|
|||||||
assertEquals("the rose", Assistant.reply(body, claude = true))
|
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
|
@Test
|
||||||
fun `an empty reply is an error, not an empty answer`() {
|
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("""{"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,8 @@
|
|||||||
|
همرسانی شعر، بیت، یا هر متنی که انتخاب کردهاید، همراه با پیوند به گنجور.
|
||||||
|
|
||||||
|
جستوجوی معنی واژه از منوی انتخاب متن، در این برنامه و در هر برنامهٔ دیگری.
|
||||||
|
|
||||||
|
دستیار هوش مصنوعی، بهصورت اختیاری و بهطور پیشفرض خاموش: سرور خودتان
|
||||||
|
(Ollama، LM Studio، llama.cpp، LocalAI) یا سرویسی مانند OpenAI، DeepSeek،
|
||||||
|
Gemini یا Claude را وصل کنید تا خلاصههای گنجور را ترجمه و بیتها را شرح دهد.
|
||||||
|
تا خودتان تنظیم نکنید چیزی به جایی فرستاده نمیشود و هیچ کلیدی همراه برنامه نیست.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
ترجمهٔ دستیار حالا زیر همان خلاصهای که ترجمه میکند، در خودِ صفحه نشان داده
|
||||||
|
میشود، نه روی آن. گنجور هم زیر هر بیت خلاصه مینویسد و هم زیر شعر؛ هر دو را
|
||||||
|
میتوان ترجمه کرد.
|
||||||
|
|
||||||
|
ذخیره، رونوشت، همرسانی و شرحِ هر بیت دوباره در دسترس است: نشانهٔ پیکان در
|
||||||
|
انتهای هر بیت آنها را باز میکند و در پای جستوجوی واژه هم هست. پیش از این
|
||||||
|
باید جایی میان دو واژه ضربه میزدید، که در یک مصراع شعر تقریباً هرگز رخ نمیدهد.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
کلام، شعر، یا کوئی بھی منتخب متن شیئر کریں، گنجور کے لنک کے ساتھ۔
|
||||||
|
|
||||||
|
متن منتخب کرنے کے مینو سے لفظ کا مطلب دیکھیں — اس ایپ میں اور کسی بھی دوسری ایپ میں۔
|
||||||
|
|
||||||
|
مصنوعی ذہانت کا اختیاری معاون، جو پہلے سے بند ہے: اپنا سرور (Ollama، LM Studio،
|
||||||
|
llama.cpp، LocalAI) یا OpenAI، DeepSeek، Gemini یا Claude جیسی سروس جوڑیں تاکہ
|
||||||
|
گنجور کے خلاصوں کا ترجمہ ہو اور اشعار کی تشریح ہو۔ جب تک آپ خود ترتیب نہ دیں کچھ
|
||||||
|
کہیں نہیں بھیجا جاتا، اور ایپ کے ساتھ کوئی کلید نہیں آتی۔
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
معاون کا ترجمہ اب اسی خلاصے کے نیچے، صفحے ہی میں دکھایا جاتا ہے جس کا ترجمہ ہو
|
||||||
|
رہا ہے — اوپر کسی الگ پردے میں نہیں۔ گنجور ہر شعر کے نیچے بھی خلاصہ لکھتا ہے
|
||||||
|
اور پورے کلام کے نیچے بھی؛ دونوں کا ترجمہ ہو سکتا ہے۔
|
||||||
|
|
||||||
|
ہر شعر کے محفوظ کرنے، نقل، شیئر اور تشریح کے بٹن دوبارہ قابلِ رسائی ہیں: ہر شعر
|
||||||
|
کے آخر میں ایک نشان انہیں کھولتا ہے، اور وہ لفظ کی لغت کے نیچے بھی موجود ہیں۔
|
||||||
|
پہلے ان کے لیے دو الفاظ کے درمیان ٹیپ کرنا پڑتا تھا، جو شعر کی سطر میں تقریباً
|
||||||
|
کبھی نہیں ہوتا۔
|
||||||
Reference in new issue
Block a user