diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml
index 2df69fd8c..0a6f344fb 100644
--- a/shared/src/commonMain/composeResources/values/strings.xml
+++ b/shared/src/commonMain/composeResources/values/strings.xml
@@ -118,4 +118,5 @@ We will process your data in accordance with the App Privacy Policy. You can adj
News
+ Speakers
diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/App.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/App.kt
index 6ac004b83..9b9e7b677 100644
--- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/App.kt
+++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/App.kt
@@ -19,6 +19,7 @@ import org.jetbrains.kotlinconf.screens.PrivacyPolicyViewModel
import org.jetbrains.kotlinconf.screens.ScheduleViewModel
import org.jetbrains.kotlinconf.screens.SessionViewModel
import org.jetbrains.kotlinconf.screens.SettingsViewModel
+import org.jetbrains.kotlinconf.screens.SpeakersViewModel
import org.jetbrains.kotlinconf.screens.StartNotificationsViewModel
import org.jetbrains.kotlinconf.storage.ApplicationStorage
import org.jetbrains.kotlinconf.storage.MultiplatformSettingsStorage
@@ -80,6 +81,7 @@ private fun koinConfiguration(context: ApplicationContext) = koinConfiguration {
viewModelOf(::NewsListViewModel)
viewModelOf(::StartNotificationsViewModel)
viewModelOf(::NewsDetailViewModel)
+ viewModelOf(::SpeakersViewModel)
}
modules(appModule, viewModelModule)
diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/AboutConference.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/AboutConference.kt
index a8f70dbef..0d7bbe7ab 100644
--- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/AboutConference.kt
+++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/AboutConference.kt
@@ -50,6 +50,7 @@ import org.jetbrains.kotlinconf.SpeakerId
import org.jetbrains.kotlinconf.ui.components.DayHeader
import org.jetbrains.kotlinconf.ui.components.Divider
import org.jetbrains.kotlinconf.ui.components.PageMenuItem
+import org.jetbrains.kotlinconf.ui.components.SpeakerCard
import org.jetbrains.kotlinconf.ui.components.StyledText
import org.jetbrains.kotlinconf.ui.theme.KotlinConfTheme
@@ -195,7 +196,7 @@ private fun Event(
for (speaker in speakers) {
key(speaker.id) {
- org.jetbrains.kotlinconf.ui.components.Speaker(speaker.name, speaker.description, speaker.photoUrl, modifier = Modifier.padding(horizontal = 12.dp))
+ SpeakerCard(speaker.name, speaker.description, speaker.photoUrl, modifier = Modifier.padding(horizontal = 12.dp))
}
}
diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt
index c4f262400..16c6a5f34 100644
--- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt
+++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/MainScreen.kt
@@ -42,6 +42,7 @@ import org.jetbrains.kotlinconf.navigation.PartnersScreen
import org.jetbrains.kotlinconf.navigation.PrivacyPolicyScreen
import org.jetbrains.kotlinconf.navigation.ScheduleScreen
import org.jetbrains.kotlinconf.navigation.SessionScreen
+import org.jetbrains.kotlinconf.navigation.SpeakerDetailsScreen
import org.jetbrains.kotlinconf.navigation.SpeakersScreen
import org.jetbrains.kotlinconf.ui.components.Divider
import org.jetbrains.kotlinconf.ui.components.MainNavDestination
@@ -79,8 +80,8 @@ fun MainScreen(
)
}
composable {
- Speakers(
- onBack = rootNavController::popBackStack
+ SpeakersScreen(
+ onSpeaker = { rootNavController.navigate(SpeakerDetailsScreen(it)) }
)
}
composable {
diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/SessionScreen.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/SessionScreen.kt
index fc1584786..66668786d 100644
--- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/SessionScreen.kt
+++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/SessionScreen.kt
@@ -54,7 +54,7 @@ import org.jetbrains.kotlinconf.ui.components.FeedbackForm
import org.jetbrains.kotlinconf.ui.components.KodeeIconLarge
import org.jetbrains.kotlinconf.ui.components.MainHeaderTitleBar
import org.jetbrains.kotlinconf.ui.components.PageTitle
-import org.jetbrains.kotlinconf.ui.components.Speaker
+import org.jetbrains.kotlinconf.ui.components.SpeakerCard
import org.jetbrains.kotlinconf.ui.components.StyledText
import org.jetbrains.kotlinconf.ui.components.TopMenuButton
import org.jetbrains.kotlinconf.ui.theme.KotlinConfTheme
@@ -125,7 +125,7 @@ fun SessionScreen(
}
speakers.forEach { speaker ->
- Speaker(
+ SpeakerCard(
name = speaker.name,
title = speaker.position,
photoUrl = speaker.photoUrl,
diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/Speakers.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/Speakers.kt
deleted file mode 100644
index b27121624..000000000
--- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/Speakers.kt
+++ /dev/null
@@ -1,24 +0,0 @@
-package org.jetbrains.kotlinconf.screens
-
-import androidx.compose.foundation.Image
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Column
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import kotlinconfapp.shared.generated.resources.Res
-import kotlinconfapp.shared.generated.resources.arrow_left_24
-import org.jetbrains.compose.resources.painterResource
-import org.jetbrains.kotlinconf.ui.components.StyledText
-
-@Composable
-fun Speakers(
- onBack: () -> Unit,
-) {
- Column {
- Image(
- painterResource(Res.drawable.arrow_left_24),
- "back",
- modifier = Modifier.clickable { onBack() })
- StyledText("Speakers placeholder")
- }
-}
diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/SpeakersScreen.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/SpeakersScreen.kt
new file mode 100644
index 000000000..657e01c8e
--- /dev/null
+++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/SpeakersScreen.kt
@@ -0,0 +1,98 @@
+package org.jetbrains.kotlinconf.screens
+
+import androidx.compose.foundation.clickable
+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.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.unit.dp
+import kotlinconfapp.shared.generated.resources.Res
+import kotlinconfapp.shared.generated.resources.speakers_title
+import kotlinconfapp.ui_components.generated.resources.main_header_search_hint
+import kotlinconfapp.ui_components.generated.resources.search_24
+import org.jetbrains.compose.resources.stringResource
+import org.jetbrains.kotlinconf.SpeakerId
+import org.jetbrains.kotlinconf.ui.components.Divider
+import org.jetbrains.kotlinconf.ui.components.MainHeaderContainer
+import org.jetbrains.kotlinconf.ui.components.MainHeaderContainerState
+import org.jetbrains.kotlinconf.ui.components.MainHeaderSearchBar
+import org.jetbrains.kotlinconf.ui.components.MainHeaderTitleBar
+import org.jetbrains.kotlinconf.ui.components.SpeakerCard
+import org.jetbrains.kotlinconf.ui.components.TopMenuButton
+import org.jetbrains.kotlinconf.ui.theme.KotlinConfTheme
+import org.koin.compose.viewmodel.koinViewModel
+import kotlinconfapp.ui_components.generated.resources.Res as UiRes
+
+@Composable
+fun SpeakersScreen(
+ onSpeaker: (SpeakerId) -> Unit,
+ viewModel: SpeakersViewModel = koinViewModel()
+) {
+ var searchState by rememberSaveable { mutableStateOf(MainHeaderContainerState.Title) }
+ var searchText by rememberSaveable { mutableStateOf("") }
+ val speakers by viewModel.speakers.collectAsState()
+
+ LaunchedEffect(searchText) {
+ viewModel.setSearchText(searchText)
+ }
+
+ Column(Modifier.fillMaxSize()) {
+ MainHeaderContainer(
+ state = searchState,
+ titleContent = {
+ MainHeaderTitleBar(
+ title = stringResource(Res.string.speakers_title),
+ endContent = {
+ TopMenuButton(
+ icon = UiRes.drawable.search_24,
+ onClick = { searchState = MainHeaderContainerState.Search },
+ contentDescription = stringResource(UiRes.string.main_header_search_hint)
+ )
+ }
+ )
+ },
+ searchContent = {
+ MainHeaderSearchBar(
+ searchValue = searchText,
+ onSearchValueChange = { searchText = it },
+ onClose = {
+ searchState = MainHeaderContainerState.Title
+ searchText = ""
+ },
+ onClear = { searchText = "" }
+ )
+ }
+ )
+
+ Divider(1.dp, KotlinConfTheme.colors.strokePale)
+
+ LazyColumn(Modifier.fillMaxSize()) {
+ items(speakers) { speaker ->
+ SpeakerCard(
+ name = speaker.name,
+ nameHighlights = speaker.nameHighlights,
+ title = speaker.position,
+ titleHighlights = speaker.titleHighlights,
+ photoUrl = speaker.photoUrl,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(12.dp)
+ .clip(RoundedCornerShape(8.dp))
+ .clickable { onSpeaker(speaker.id) }
+ )
+ }
+ }
+ }
+}
diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/SpeakersViewModel.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/SpeakersViewModel.kt
new file mode 100644
index 000000000..b1e3f4c6e
--- /dev/null
+++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/screens/SpeakersViewModel.kt
@@ -0,0 +1,61 @@
+package org.jetbrains.kotlinconf.screens
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.stateIn
+import org.jetbrains.kotlinconf.ConferenceService
+import org.jetbrains.kotlinconf.Speaker
+import org.jetbrains.kotlinconf.SpeakerId
+import org.jetbrains.kotlinconf.utils.containsDiacritics
+import org.jetbrains.kotlinconf.utils.removeDiacritics
+
+data class SpeakerWithHighlights(
+ val speaker: Speaker,
+ val nameHighlights: List,
+ val titleHighlights: List,
+) {
+ val id: SpeakerId get() = speaker.id
+ val name: String get() = speaker.name
+ val position: String get() = speaker.position
+ val photoUrl: String get() = speaker.photoUrl
+}
+
+class SpeakersViewModel(
+ service: ConferenceService,
+) : ViewModel() {
+ private var searchText = MutableStateFlow("")
+
+ fun setSearchText(searchText: String) {
+ this.searchText.value = searchText
+ }
+
+ val speakers = combine(service.speakers, searchText) { speakers, searchText ->
+ if (searchText.isBlank()) {
+ speakers.all.map { SpeakerWithHighlights(it, emptyList(), emptyList()) }
+ } else {
+ speakers.all.mapNotNull {
+ // Look for exact matches if diacritics are present, ignore all diacritics otherwise
+ val diacriticsSearch = searchText.containsDiacritics()
+ val targetName = if (diacriticsSearch) it.name else it.name.removeDiacritics()
+ val targetPosition = if (diacriticsSearch) it.position else it.position.removeDiacritics()
+ val searchPattern = searchText.toRegex(RegexOption.IGNORE_CASE)
+
+ val nameMatches = searchPattern.findAll(targetName).map { it.range }.toList()
+ val titleMatches = searchPattern.findAll(targetPosition).map { it.range }.toList()
+
+ if (nameMatches.isNotEmpty() || titleMatches.isNotEmpty()) {
+ SpeakerWithHighlights(it, nameMatches, titleMatches)
+ } else {
+ null
+ }
+ }
+ }
+ }
+ .flowOn(Dispatchers.Default)
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
+}
diff --git a/ui-components/src/commonMain/kotlin/org/jetbrains/kotlinconf/ui/Gallery.kt b/ui-components/src/commonMain/kotlin/org/jetbrains/kotlinconf/ui/Gallery.kt
index 26dea4405..555cbddb1 100644
--- a/ui-components/src/commonMain/kotlin/org/jetbrains/kotlinconf/ui/Gallery.kt
+++ b/ui-components/src/commonMain/kotlin/org/jetbrains/kotlinconf/ui/Gallery.kt
@@ -42,7 +42,7 @@ fun GalleryApp() {
SectionTitlePreview()
ServiceEventsPreview()
SettingsItemPreview()
- SpeakerPreview()
+ SpeakerCardPreview()
SwitcherItemPreview()
SwitcherPreview()
TalkCardPreview()
diff --git a/ui-components/src/commonMain/kotlin/org/jetbrains/kotlinconf/ui/components/Speaker.kt b/ui-components/src/commonMain/kotlin/org/jetbrains/kotlinconf/ui/components/SpeakerCard.kt
similarity index 85%
rename from ui-components/src/commonMain/kotlin/org/jetbrains/kotlinconf/ui/components/Speaker.kt
rename to ui-components/src/commonMain/kotlin/org/jetbrains/kotlinconf/ui/components/SpeakerCard.kt
index 5e0339cfc..65161b2e1 100644
--- a/ui-components/src/commonMain/kotlin/org/jetbrains/kotlinconf/ui/components/Speaker.kt
+++ b/ui-components/src/commonMain/kotlin/org/jetbrains/kotlinconf/ui/components/SpeakerCard.kt
@@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
@@ -16,7 +15,6 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import kotlinconfapp.ui_components.generated.resources.Res
-import kotlinconfapp.ui_components.generated.resources.kodee_emotion_negative
import kotlinconfapp.ui_components.generated.resources.kodee_emotion_neutral
import kotlinconfapp.ui_components.generated.resources.kodee_emotion_positive
import org.jetbrains.compose.resources.painterResource
@@ -25,11 +23,13 @@ import org.jetbrains.kotlinconf.ui.theme.KotlinConfTheme
import org.jetbrains.kotlinconf.ui.theme.PreviewHelper
@Composable
-fun Speaker(
+fun SpeakerCard(
name: String,
title: String,
photoUrl: String,
modifier: Modifier = Modifier,
+ nameHighlights: List = emptyList(),
+ titleHighlights: List = emptyList(),
) {
Row(
modifier = modifier,
@@ -49,13 +49,13 @@ fun Speaker(
)
Column {
StyledText(
- text = name,
+ text = buildHighlightedString(name, nameHighlights),
style = KotlinConfTheme.typography.h3,
color = KotlinConfTheme.colors.primaryText,
)
Spacer(modifier = Modifier.size(6.dp))
StyledText(
- text = title,
+ text = buildHighlightedString(title, titleHighlights),
style = KotlinConfTheme.typography.text2,
color = KotlinConfTheme.colors.secondaryText,
)
@@ -65,16 +65,18 @@ fun Speaker(
@Preview
@Composable
-internal fun SpeakerPreview() {
+internal fun SpeakerCardPreview() {
PreviewHelper {
- Speaker(
+ SpeakerCard(
name = "John Doe",
title = "Whatever Role Name at That Company",
photoUrl = "https://example.com/not-an-image.jpg",
)
- Speaker(
+ SpeakerCard(
name = "John Doe",
+ nameHighlights = listOf(0..3), // Highlight "John"
title = "Whatever Role Name at That Company",
+ titleHighlights = listOf(9..12), // Highlight "Role"
photoUrl = "https://sessionize.com/image/2e2f-0o0o0-XGxKBoqZvxxQxosrZHQHTT.png?download=sebastian-aigner.png",
)
}
diff --git a/ui-components/src/commonMain/kotlin/org/jetbrains/kotlinconf/ui/components/TalkCard.kt b/ui-components/src/commonMain/kotlin/org/jetbrains/kotlinconf/ui/components/TalkCard.kt
index cd59c961d..d9c400380 100644
--- a/ui-components/src/commonMain/kotlin/org/jetbrains/kotlinconf/ui/components/TalkCard.kt
+++ b/ui-components/src/commonMain/kotlin/org/jetbrains/kotlinconf/ui/components/TalkCard.kt
@@ -60,7 +60,7 @@ import org.jetbrains.kotlinconf.ui.theme.KotlinConfTheme
import org.jetbrains.kotlinconf.ui.theme.PreviewHelper
@Composable
-private fun buildHighlightedString(
+internal fun buildHighlightedString(
text: String,
highlights: List,
): AnnotatedString = buildAnnotatedString {