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 {