Wire up the optional AI assistant

The page existed but nothing reached it. It now has a route, a provider in
MainActivity, and an entry at the foot of the reading-settings sheet beside
About, where it reads as one more optional setting rather than a feature of
the app.

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Anas Rashid 2026-10-04 18:49:37 +02:00
parent 3d4f7aae3a
commit 576a07ae31
9 changed files with 87 additions and 5 deletions

View File

@ -16,6 +16,8 @@ import com.ganjoor.android.data.Dictionary
import com.ganjoor.android.data.Ganjoor import com.ganjoor.android.data.Ganjoor
import com.ganjoor.android.data.LocalBookmarks import com.ganjoor.android.data.LocalBookmarks
import com.ganjoor.android.ui.GanjoorApp import com.ganjoor.android.ui.GanjoorApp
import com.ganjoor.android.data.AssistantSettings
import com.ganjoor.android.data.LocalAssistant
import com.ganjoor.android.ui.LocalSettings import com.ganjoor.android.ui.LocalSettings
import com.ganjoor.android.ui.Settings import com.ganjoor.android.ui.Settings
import com.ganjoor.android.ui.theme.GanjoorTheme import com.ganjoor.android.ui.theme.GanjoorTheme
@ -53,11 +55,13 @@ class MainActivity : ComponentActivity() {
setContent { setContent {
val settings = remember { settings } val settings = remember { settings }
val bookmarks = remember { Bookmarks(applicationContext) } val bookmarks = remember { Bookmarks(applicationContext) }
val assistant = remember { AssistantSettings(applicationContext) }
// The client reads this flag on every request, so keep it in step with the setting. // The client reads this flag on every request, so keep it in step with the setting.
Ganjoor.offline = settings.value.offline Ganjoor.offline = settings.value.offline
CompositionLocalProvider( CompositionLocalProvider(
LocalSettings provides settings, LocalSettings provides settings,
LocalBookmarks provides bookmarks, LocalBookmarks provides bookmarks,
LocalAssistant provides assistant,
// The poetry is Persian, Urdu and Arabic throughout, so the whole app reads and // The poetry is Persian, Urdu and Arabic throughout, so the whole app reads and
// navigates right-to-left whichever UI language is selected. // navigates right-to-left whichever UI language is selected.
LocalLayoutDirection provides LayoutDirection.Rtl, LocalLayoutDirection provides LayoutDirection.Rtl,

View File

@ -16,7 +16,9 @@ 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.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@ -46,6 +48,26 @@ fun AssistantResultSheet(prompt: String, text: String, onDismiss: () -> Unit) {
} }
} }
/**
* 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.
*/
@Composable
fun AssistantAction(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) }
TextButton(onClick = {
if (assistant.serverReady) open = true else context.shareText("$ask\n\n$text")
}) {
Text(stringResource(label))
}
if (open) AssistantResultSheet(prompt = prompt, text = text, onDismiss = { open = false })
}
/** The same answer as a plain screen, for the selection-menu activity. */ /** The same answer as a plain screen, for the selection-menu activity. */
@Composable @Composable
fun AssistantAnswer(text: String, prompt: String = "translate") { fun AssistantAnswer(text: String, prompt: String = "translate") {

View File

@ -42,6 +42,9 @@ object DownloadsRoute
@Serializable @Serializable
object AboutRoute object AboutRoute
@Serializable
object AssistantRoute
/** /**
* Opens a page with the poet list as the only thing beneath it. * Opens a page with the poet list as the only thing beneath it.
* *
@ -75,6 +78,10 @@ fun GanjoorApp() {
settingsOpen = false settingsOpen = false
nav.navigate(AboutRoute) nav.navigate(AboutRoute)
}, },
LocalOpenAssistant provides {
settingsOpen = false
nav.navigate(AssistantRoute)
},
) { ) {
NavHost( NavHost(
navController = nav, navController = nav,
@ -144,6 +151,9 @@ fun GanjoorApp() {
composable<DownloadsRoute> { composable<DownloadsRoute> {
DownloadsScreen(onUp = { nav.goHome() }) DownloadsScreen(onUp = { nav.goHome() })
} }
composable<AssistantRoute> {
AssistantScreen(onUp = { nav.navigateUp() }, onHome = { nav.goHome() })
}
composable<AboutRoute> { composable<AboutRoute> {
AboutScreen(onUp = { nav.navigateUp() }, onHome = { nav.goHome() }) AboutScreen(onUp = { nav.navigateUp() }, onHome = { nav.goHome() })
} }

View File

@ -387,6 +387,12 @@ 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,
)
} }
} }
@ -405,6 +411,12 @@ private fun PoemSummary(summary: String) {
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
AssistantAction(
prompt = "summary",
text = summary,
label = R.string.assistant_translate,
instruction = R.string.assistant_translate_prompt,
)
} }
} }

View File

@ -44,6 +44,7 @@ val LocalOpenReadingSettings = staticCompositionLocalOf<() -> Unit> {
/** Opens the About and licences screen; the sheet is the only place that needs it. */ /** Opens the About and licences screen; the sheet is the only place that needs it. */
val LocalOpenAbout = staticCompositionLocalOf<() -> Unit> { error("No about host") } 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. */ /** Top-bar button that opens the reading settings sheet. */
@Composable @Composable
@ -137,11 +138,13 @@ fun ReadingSettingsSheet(onDismiss: () -> Unit) {
settings.update { it.copy(showSummaries = on) } 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 val openAbout = LocalOpenAbout.current
TextButton( TextButton(onClick = openAbout) {
onClick = openAbout,
modifier = Modifier.padding(top = 16.dp),
) {
Text(stringResource(R.string.about)) Text(stringResource(R.string.about))
} }
} }

View File

@ -103,4 +103,6 @@
<string name="selection_actions">چه کاری انجام شود؟</string> <string name="selection_actions">چه کاری انجام شود؟</string>
<string name="assistant_ask">پرسش از دستیار</string> <string name="assistant_ask">پرسش از دستیار</string>
<string name="assistant_ask_prompt">این شعر فارسی را ترجمه کن و معنایش را توضیح بده:</string> <string name="assistant_ask_prompt">این شعر فارسی را ترجمه کن و معنایش را توضیح بده:</string>
<string name="assistant_title">هوش مصنوعی</string>
<string name="assistant_translate_prompt">این متن را ترجمه کن:</string>
</resources> </resources>

View File

@ -72,7 +72,7 @@
<string name="oled_note">تاریک تھیمز پر لاگو ہوتا ہے؛ OLED اسکرین پر بیٹری بچاتا ہے</string> <string name="oled_note">تاریک تھیمز پر لاگو ہوتا ہے؛ OLED اسکرین پر بیٹری بچاتا ہے</string>
<string name="play_recitation">قرات سنیں</string> <string name="play_recitation">قرات سنیں</string>
<string name="pause">وقفہ</string> <string name="pause">وقفہ</string>
<string name="assistant">اے آئی معاون</string> <string name="assistant">مصنوعی ذہانت کا معاون</string>
<string name="assistant_intro">اختیاری ہے۔ ایپ کا ہر حصہ اس کے بغیر کام کرتا ہے۔ جب تک آپ خود ترتیب نہ دیں کچھ کہیں نہیں بھیجا جاتا، اور ایپ کے ساتھ کوئی اکاؤنٹ یا کلید نہیں آتی۔</string> <string name="assistant_intro">اختیاری ہے۔ ایپ کا ہر حصہ اس کے بغیر کام کرتا ہے۔ جب تک آپ خود ترتیب نہ دیں کچھ کہیں نہیں بھیجا جاتا، اور ایپ کے ساتھ کوئی اکاؤنٹ یا کلید نہیں آتی۔</string>
<string name="assistant_mode">ماڈل تک رسائی کا طریقہ</string> <string name="assistant_mode">ماڈل تک رسائی کا طریقہ</string>
<string name="assistant_off">بند</string> <string name="assistant_off">بند</string>
@ -103,4 +103,6 @@
<string name="selection_actions">آپ کیا کرنا چاہیں گے؟</string> <string name="selection_actions">آپ کیا کرنا چاہیں گے؟</string>
<string name="assistant_ask">اسسٹنٹ سے پوچھیں</string> <string name="assistant_ask">اسسٹنٹ سے پوچھیں</string>
<string name="assistant_ask_prompt">اس فارسی کلام کا ترجمہ کریں اور اس کا مطلب بیان کریں:</string> <string name="assistant_ask_prompt">اس فارسی کلام کا ترجمہ کریں اور اس کا مطلب بیان کریں:</string>
<string name="assistant_title">مصنوعی ذہانت</string>
<string name="assistant_translate_prompt">اس کا ترجمہ کریں:</string>
</resources> </resources>

View File

@ -108,4 +108,6 @@
<string name="selection_actions">What would you like to do?</string> <string name="selection_actions">What would you like to do?</string>
<string name="assistant_ask">Ask an assistant</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_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>
</resources> </resources>

View File

@ -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"))
}
}