Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions examples/android/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,12 @@
android:name=".showcase.compose.agent.AgentStudioShowcase"
android:parentActivityName=".directory.DirectoryActivity" />

<!-- Composition (experimental) -->

<activity
android:name=".showcase.compose.composition.CompositionShowcase"
android:parentActivityName=".directory.DirectoryActivity" />

<!-- Showcase: shared -->

<activity
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import com.algolia.instantsearch.examples.android.guides.gettingstarted.GettingS
import com.algolia.instantsearch.examples.android.guides.insights.InsightsActivity
import com.algolia.instantsearch.examples.android.showcase.androidview.directory.AndroidViewDirectoryShowcase
import com.algolia.instantsearch.examples.android.showcase.compose.agent.AgentStudioShowcase
import com.algolia.instantsearch.examples.android.showcase.compose.composition.CompositionShowcase
import com.algolia.instantsearch.examples.android.showcase.compose.directory.ComposeDirectoryShowcase
import com.algolia.search.helper.deserialize
import kotlin.reflect.KClass
Expand Down Expand Up @@ -47,6 +48,10 @@ private val experimentalItems = listOf(
DirectoryHit(objectID = "experimental_agentic_experience", name = "Agentic Experience", type = "Experimental"),
AgentStudioShowcase::class,
),
DirectoryItem.Item(
DirectoryHit(objectID = "experimental_composition_search", name = "Composition Search", type = "Experimental"),
CompositionShowcase::class,
),
)

internal fun directoryItems(response: SearchResponse, mappings: Map<String, KClass<out ComponentActivity>>) =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
package com.algolia.instantsearch.examples.android.showcase.compose.composition

import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.Arrangement
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.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.Button
import androidx.compose.material.Divider
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
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.unit.dp
import com.algolia.client.model.search.Hit
import com.algolia.instantsearch.compose.hits.HitsState
import com.algolia.instantsearch.compose.item.StatsTextState
import com.algolia.instantsearch.compose.searchbox.SearchBoxState
import com.algolia.instantsearch.core.connection.ConnectionHandler
import com.algolia.instantsearch.core.hits.connectHitsView
import com.algolia.instantsearch.examples.android.showcase.compose.ui.ShowcaseTheme
import com.algolia.instantsearch.examples.android.showcase.compose.ui.component.SearchTopBar
import com.algolia.instantsearch.examples.android.showcase.compose.ui.component.TitleTopBar
import com.algolia.instantsearch.searchbox.SearchBoxConnector
import com.algolia.instantsearch.searchbox.connectView
import com.algolia.instantsearch.searcher.composition.CompositionSearcher
import com.algolia.instantsearch.stats.DefaultStatsPresenter
import com.algolia.instantsearch.stats.StatsConnector
import com.algolia.instantsearch.stats.connectView
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull

/**
* Showcase for [CompositionSearcher], the searcher targeting the Algolia
* Composition API (`/1/compositions/{compositionID}/run` endpoint).
*
* A composition is configured per application in the Algolia dashboard, so
* there is no public demo composition to hardcode here. The screen starts with
* a small form asking for your application credentials and composition ID,
* then drives the regular InstantSearch components (search box, stats, hits)
* through a [CompositionSearcher].
*/
class CompositionShowcase : AppCompatActivity() {

private var session by mutableStateOf<CompositionSession?>(null)

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ShowcaseTheme {
when (val current = session) {
null -> CredentialsForm { applicationID, apiKey, compositionID ->
session = CompositionSession(applicationID, apiKey, compositionID)
}
else -> CompositionSearchScreen(current)
}
}
}
}

override fun onDestroy() {
super.onDestroy()
session?.close()
}
}

/**
* Holds the searcher and its connections, created once the credentials are
* submitted.
*/
private class CompositionSession(
applicationID: String,
apiKey: String,
compositionID: String,
) {
val searchBoxState = SearchBoxState()
val statsText = StatsTextState()
val hitsState = HitsState<CompositionHit>()
var error by mutableStateOf<String?>(null)

private val searcher = CompositionSearcher(
applicationID = applicationID,
apiKey = apiKey,
compositionID = compositionID,
)
private val searchBox = SearchBoxConnector(searcher)
private val stats = StatsConnector(searcher)
private val connections = ConnectionHandler(searchBox, stats)

init {
connections += searchBox.connectView(searchBoxState)
connections += stats.connectView(statsText, DefaultStatsPresenter())
connections += searcher.connectHitsView(hitsState) { response ->
response.hits.map { it.toCompositionHit() }
}
searcher.error.subscribe { error = it?.message }
searcher.searchAsync()
}

fun close() {
searcher.error.unsubscribeAll()
searcher.cancel()
connections.clear()
}
}

/**
* Composition records don't have a fixed schema, so the row displays the most
* common "title" attributes and falls back to the objectID.
*/
private data class CompositionHit(
val objectID: String,
val title: String,
)

private fun Hit.toCompositionHit(): CompositionHit {
val title = listOf("name", "title", "label")
.firstNotNullOfOrNull { attribute ->
(additionalProperties?.get(attribute) as? JsonPrimitive)?.contentOrNull
}
return CompositionHit(objectID = objectID, title = title ?: objectID)
}

@Composable
private fun CredentialsForm(onSubmit: (String, String, String) -> Unit) {
var applicationID by remember { mutableStateOf("") }
var apiKey by remember { mutableStateOf("") }
var compositionID by remember { mutableStateOf("") }

Scaffold(
topBar = { TitleTopBar(title = "Composition (experimental)") },
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "Compositions are configured per application in the Algolia dashboard. " +
"Enter the credentials of an application with at least one composition.",
Comment on lines +153 to +154

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so the latency app does not composition configured on it atm?

style = MaterialTheme.typography.body2,
)
OutlinedTextField(
value = applicationID,
onValueChange = { applicationID = it },
modifier = Modifier.fillMaxWidth(),
label = { Text("Application ID") },
singleLine = true,
)
OutlinedTextField(
value = apiKey,
onValueChange = { apiKey = it },
modifier = Modifier.fillMaxWidth(),
label = { Text("Search-only API Key") },
singleLine = true,
)
OutlinedTextField(
value = compositionID,
onValueChange = { compositionID = it },
modifier = Modifier.fillMaxWidth(),
label = { Text("Composition ID") },
singleLine = true,
)
Button(
enabled = applicationID.isNotBlank() && apiKey.isNotBlank() && compositionID.isNotBlank(),
onClick = { onSubmit(applicationID.trim(), apiKey.trim(), compositionID.trim()) },
) {
Text("Start searching")
}
}
}
}

@Composable
private fun CompositionSearchScreen(session: CompositionSession) {
val listState = rememberLazyListState()
Scaffold(
topBar = {
SearchTopBar(
placeHolderText = "Search in your composition",
searchBoxState = session.searchBoxState,
lazyListState = listState,
)
},
) { padding ->
Column(
Modifier
.fillMaxSize()
.padding(padding),
) {
Text(
text = session.statsText.stats,
style = MaterialTheme.typography.caption,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
session.error?.let {
Text(
text = it,
color = MaterialTheme.colors.error,
style = MaterialTheme.typography.caption,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
LazyColumn(state = listState) {
items(session.hitsState.hits) { hit ->
Column(
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Text(text = hit.title, style = MaterialTheme.typography.body1)
Text(
text = hit.objectID,
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
)
}
Divider()
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package com.algolia.instantsearch.searcher.composition

import com.algolia.client.api.CompositionClient
import com.algolia.client.model.search.SearchParamsObject
import com.algolia.client.transport.RequestOptions
import com.algolia.instantsearch.searcher.SearcherForFacets
import com.algolia.instantsearch.searcher.SearcherScope
import com.algolia.instantsearch.searcher.composition.internal.DefaultCompositionFacetsSearchService
import com.algolia.instantsearch.searcher.composition.internal.DefaultCompositionFacetsSearcher
import com.algolia.instantsearch.searcher.facets.SearchForFacetQuery
import com.algolia.instantsearch.searcher.internal.defaultDispatcher
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope

/**
* The component handling search requests and managing the search sessions.
* This implementation searches for facet values using the Algolia Composition API
* (`/1/compositions/{compositionID}/facets/{facetName}/query` endpoint).
*/
public interface CompositionFacetsSearcher : SearcherForFacets<SearchParamsObject> {

/**
* Unique Composition ObjectID.
*/
public var compositionID: String

/**
* Facets query.
*/
public var facetQuery: String?

/**
* Maximum number of facet values to return.
*/
public var maxFacetHits: Int?
}

/**
* Creates an instance of [CompositionFacetsSearcher].
*
* @param client composition client instance
* @param compositionID unique Composition ObjectID
* @param attribute facet attribute
* @param query the query used to narrow down the facet values search
* @param facetQuery the facet query used to search for facets
* @param maxFacetHits maximum number of facet values to return
* @param requestOptions request local configuration
* @param coroutineScope scope of coroutine operations
* @param coroutineDispatcher async search dispatcher
* @param triggerSearchFor request condition
*/
public fun CompositionFacetsSearcher(
client: CompositionClient,
compositionID: String,
attribute: String,
query: SearchParamsObject = SearchParamsObject(),
facetQuery: String? = null,
maxFacetHits: Int? = null,
requestOptions: RequestOptions? = null,
coroutineScope: CoroutineScope = SearcherScope(),
coroutineDispatcher: CoroutineDispatcher = defaultDispatcher,
triggerSearchFor: SearchForFacetQuery = SearchForFacetQuery.All,
): CompositionFacetsSearcher = DefaultCompositionFacetsSearcher(
searchService = DefaultCompositionFacetsSearchService(client),
compositionID = compositionID,
query = query,
attribute = attribute,
facetQuery = facetQuery,
maxFacetHits = maxFacetHits,
requestOptions = requestOptions,
coroutineScope = coroutineScope,
coroutineDispatcher = coroutineDispatcher,
triggerSearchFor = triggerSearchFor,
)

/**
* Creates an instance of [CompositionFacetsSearcher].
*
* @param applicationID application ID
* @param apiKey API Key
* @param compositionID unique Composition ObjectID
* @param attribute facet attribute
* @param query the query used to narrow down the facet values search
* @param facetQuery the facet query used to search for facets
* @param maxFacetHits maximum number of facet values to return
* @param requestOptions request local configuration
* @param coroutineScope scope of coroutine operations
* @param coroutineDispatcher async search dispatcher
* @param triggerSearchFor request condition
*/
public fun CompositionFacetsSearcher(
applicationID: String,
apiKey: String,
compositionID: String,
attribute: String,
query: SearchParamsObject = SearchParamsObject(),
facetQuery: String? = null,
maxFacetHits: Int? = null,
requestOptions: RequestOptions? = null,
coroutineScope: CoroutineScope = SearcherScope(),
coroutineDispatcher: CoroutineDispatcher = defaultDispatcher,
triggerSearchFor: SearchForFacetQuery = SearchForFacetQuery.All,
): CompositionFacetsSearcher = CompositionFacetsSearcher(
client = CompositionClient(applicationID, apiKey),
compositionID = compositionID,
attribute = attribute,
query = query,
facetQuery = facetQuery,
maxFacetHits = maxFacetHits,
requestOptions = requestOptions,
coroutineScope = coroutineScope,
coroutineDispatcher = coroutineDispatcher,
triggerSearchFor = triggerSearchFor,
)
Loading
Loading