diff --git a/app/src/main/java/be/scri/ui/screens/ConjugateScreen.kt b/app/src/main/java/be/scri/ui/screens/ConjugateScreen.kt index 4f7a67cf..d11eabed 100644 --- a/app/src/main/java/be/scri/ui/screens/ConjugateScreen.kt +++ b/app/src/main/java/be/scri/ui/screens/ConjugateScreen.kt @@ -2,10 +2,12 @@ package be.scri.ui.screens +import android.widget.Toast import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -17,22 +19,30 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel import be.scri.R import be.scri.ui.common.ScribeBaseScreen @@ -43,10 +53,16 @@ import be.scri.ui.common.ScribeBaseScreen fun ConjugateScreen( onNavigateToDownloadData: () -> Unit, modifier: Modifier = Modifier, + viewModel: ConjugateViewModel = viewModel(), ) { + val context = LocalContext.current val localConfiguration = LocalConfiguration.current val scrollState = rememberScrollState() + val searchQuery by viewModel.searchQuery.collectAsState() + val searchResults by viewModel.searchResults.collectAsState() + val recentlyConjugated by viewModel.recentlyConjugated.collectAsState() + val dynamicSpacing = localConfiguration.screenHeightDp.dp * 0.1f ScribeBaseScreen { Column( @@ -72,8 +88,10 @@ fun ConjugateScreen( .height(122.dp), contentScale = ContentScale.Fit, ) + + // Header 1: Conjugate verbs Text( - text = stringResource(R.string.i18n_app_download_menu_option_conjugate_title), + text = stringResource(R.string.i18n_app_conjugate_verbs_search_title), color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.headlineMedium, @@ -85,53 +103,328 @@ fun ConjugateScreen( bottom = Dimensions.PaddingSmall, ).align(Alignment.Start), ) - Card( + + // Search Bar + Row( modifier = Modifier .fillMaxWidth() - .padding(bottom = Dimensions.PaddingSmall) - .clickable { - onNavigateToDownloadData() - }, - shape = RoundedCornerShape(dimensionResource(id = R.dimen.rounded_corner_radius_standard)), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface, - ), + .height(56.dp) + .background( + color = MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(dimensionResource(id = R.dimen.rounded_corner_radius_standard)), + ).padding(horizontal = Dimensions.PaddingMedium), + verticalAlignment = Alignment.CenterVertically, ) { - Column( + Image( + painter = painterResource(id = R.drawable.ic_search_vector), + contentDescription = "Search", + colorFilter = ColorFilter.tint(Color.Black), + modifier = Modifier.size(Dimensions.IconSize), + ) + + Spacer(modifier = Modifier.width(12.dp)) + + BasicTextField( + value = searchQuery, + onValueChange = { viewModel.onSearchQueryChanged(it) }, + textStyle = + MaterialTheme.typography.bodyLarge.copy( + color = Color.Black, + fontWeight = FontWeight.Bold, + ), + singleLine = true, + cursorBrush = SolidColor(Color.Black), + modifier = Modifier.weight(1f), + decorationBox = { innerTextField -> + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.CenterStart, + ) { + if (searchQuery.isEmpty()) { + Text( + text = stringResource(R.string.i18n_app_conjugate_verbs_search_placeholder), + color = Color.Black.copy(alpha = 0.6f), + style = + MaterialTheme.typography.bodyLarge.copy( + fontWeight = FontWeight.Bold, + ), + ) + } + innerTextField() + } + }, + ) + + if (searchQuery.isNotEmpty()) { + Image( + painter = painterResource(id = R.drawable.close), + contentDescription = "Clear", + colorFilter = ColorFilter.tint(Color.Black), + modifier = + Modifier + .size(Dimensions.IconSize) + .clickable { viewModel.clearSearchQuery() }, + ) + Spacer(modifier = Modifier.width(8.dp)) + } + + Image( + painter = painterResource(id = R.drawable.play_button), + contentDescription = "Play button", + colorFilter = ColorFilter.tint(Color.Black), + modifier = + Modifier + .width(21.dp) + .height(18.dp) + .clickable { + if (searchResults.isNotEmpty()) { + viewModel.onVerbSelected(searchResults.first()) + } + }, + ) + } + + Spacer(modifier = Modifier.height(Dimensions.PaddingMedium)) + + if (searchQuery.isNotEmpty()) { + // Search suggestion container + Card( + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = Dimensions.PaddingMedium), + shape = RoundedCornerShape(dimensionResource(id = R.dimen.rounded_corner_radius_standard)), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = Dimensions.ElevationSmall), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(Dimensions.PaddingSmall), + ) { + if (searchResults.isEmpty()) { + Text( + text = "No verbs found", + modifier = Modifier.padding(Dimensions.PaddingMedium), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = Alpha.MEDIUM), + style = MaterialTheme.typography.bodyMedium, + ) + } else { + searchResults.forEachIndexed { index, result -> + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { viewModel.onVerbSelected(result) } + .padding(Dimensions.PaddingMedium), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "${result.verb} (${getLanguageDisplayName(result.languageAlias)})", + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.labelMedium, + ) + Image( + painter = painterResource(id = R.drawable.right_arrow), + contentDescription = "Right Arrow", + modifier = + Modifier + .size(Dimensions.IconSize) + .alpha(Alpha.HIGH), + ) + } + if (index < searchResults.lastIndex) { + Spacer( + modifier = + Modifier + .fillMaxWidth() + .height(1.dp) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f)), + ) + } + } + } + } + } + } else { + // normal screen flow when not searching + + // Header 2: Verb data + Text( + text = stringResource(R.string.i18n_app_download_menu_option_conjugate_title), + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.headlineMedium, + modifier = + Modifier + .padding( + start = 4.dp, + top = Dimensions.PaddingLarge, + bottom = Dimensions.PaddingSmall, + ).align(Alignment.Start), + ) + Card( modifier = - Modifier.Companion - .padding(Dimensions.PaddingMedium) - .fillMaxWidth(), + Modifier + .fillMaxWidth() + .padding(bottom = Dimensions.PaddingSmall) + .clickable { + onNavigateToDownloadData() + }, + shape = RoundedCornerShape(dimensionResource(id = R.dimen.rounded_corner_radius_standard)), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), ) { + Column( + modifier = + Modifier + .padding(Dimensions.PaddingMedium) + .fillMaxWidth(), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.i18n_app_download_menu_option_conjugate_download_data), + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.labelMedium, + ) + Image( + painter = painterResource(R.drawable.right_arrow), + contentDescription = "Right Arrow", + modifier = + Modifier + .size(Dimensions.IconSize) + .alpha(Alpha.HIGH), + ) + } + Text( + text = stringResource(R.string.i18n_app_download_menu_option_conjugate_description), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = Alpha.MEDIUM), + style = MaterialTheme.typography.bodySmall, + ) + } + } + + // Header 3: Recently conjugated + if (recentlyConjugated.isNotEmpty()) { Row( - modifier = Modifier.fillMaxWidth(), + modifier = + Modifier + .fillMaxWidth() + .padding(top = Dimensions.PaddingLarge, bottom = Dimensions.PaddingSmall), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { Text( - text = stringResource(R.string.i18n_app_download_menu_option_conjugate_download_data_start), - fontWeight = FontWeight.Bold, + text = stringResource(R.string.i18n_app_conjugate_recently_conjugated_title), color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.headlineMedium, ) - Image( - painter = painterResource(R.drawable.right_arrow), - contentDescription = "Right Arrow", + + Row( modifier = - Modifier.Companion - .size(Dimensions.IconSize) - .alpha(Alpha.HIGH), - ) + Modifier + .background(MaterialTheme.colorScheme.primary, RoundedCornerShape(4.dp)) + .clickable { viewModel.clearAllRecentlyConjugated() } + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Clear all ✕", + fontWeight = FontWeight.Bold, + color = Color.Black, + style = MaterialTheme.typography.bodySmall, + ) + } + } + + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(dimensionResource(id = R.dimen.rounded_corner_radius_standard)), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + recentlyConjugated.forEachIndexed { index, item -> + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(Dimensions.PaddingMedium) + .clickable { + Toast + .makeText( + context, + "Conjugating ${item.verb} (${getLanguageDisplayName(item.languageAlias)})...", + Toast.LENGTH_SHORT, + ).show() + }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "${item.verb} (${getLanguageDisplayName(item.languageAlias)})", + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.labelMedium, + ) + Image( + painter = painterResource(id = R.drawable.right_arrow), + contentDescription = "Right Arrow", + modifier = + Modifier + .size(Dimensions.IconSize) + .alpha(Alpha.HIGH), + ) + } + if (index < recentlyConjugated.lastIndex) { + Spacer( + modifier = + Modifier + .fillMaxWidth() + .height(1.dp) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f)), + ) + } + } + } } - Text( - text = stringResource(R.string.i18n_app_download_menu_option_conjugate_description), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = Alpha.MEDIUM), - style = MaterialTheme.typography.bodySmall, - ) } } } } } + +/** + * Returns the native/endonym name of a language alias. + */ +fun getLanguageDisplayName(alias: String): String = + when (alias.uppercase()) { + "EN" -> "English" + "FR" -> "Français" + "DE" -> "Deutsch" + "IT" -> "Italiano" + "PT" -> "Português" + "RU" -> "Русский" + "ES" -> "Español" + "SV" -> "Svenska" + else -> alias + } diff --git a/app/src/main/java/be/scri/ui/screens/ConjugateViewModel.kt b/app/src/main/java/be/scri/ui/screens/ConjugateViewModel.kt new file mode 100644 index 00000000..b5f4be93 --- /dev/null +++ b/app/src/main/java/be/scri/ui/screens/ConjugateViewModel.kt @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package be.scri.ui.screens + +import android.app.Application +import android.content.Context +import android.util.Log +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import be.scri.helpers.DatabaseFileManager +import be.scri.helpers.data.columnExists +import be.scri.helpers.data.tableExists +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Data class representing a search result for a verb. + */ +data class ConjugateSearchResult( + val verb: String, + val languageAlias: String, +) + +/** + * ViewModel to manage verb search and recently conjugated items on the Conjugate screen. + */ +class ConjugateViewModel( + application: Application, +) : AndroidViewModel(application) { + private val prefs = application.getSharedPreferences("scribe_conjugate_search_prefs", Context.MODE_PRIVATE) + + private val _searchQuery = MutableStateFlow("") + val searchQuery = _searchQuery.asStateFlow() + + private val _searchResults = MutableStateFlow>(emptyList()) + val searchResults = _searchResults.asStateFlow() + + private val _recentlyConjugated = MutableStateFlow>(emptyList()) + val recentlyConjugated = _recentlyConjugated.asStateFlow() + + init { + loadRecentlyConjugated() + } + + /** + * Updates the search query and triggers database search if not blank. + */ + fun onSearchQueryChanged(query: String) { + _searchQuery.value = query + if (query.isBlank()) { + _searchResults.value = emptyList() + } else { + performSearch(query) + } + } + + /** + * Clears the current search query. + */ + fun clearSearchQuery() { + onSearchQueryChanged("") + } + + private fun performSearch(query: String) { + viewModelScope.launch(Dispatchers.IO) { + val results = mutableListOf() + val fileManager = DatabaseFileManager(getApplication()) + val aliases = listOf("EN", "FR", "DE", "IT", "PT", "RU", "ES", "SV") + + for (alias in aliases) { + val db = fileManager.getConjugateDatabase(alias) ?: continue + + try { + val columnName = if (alias == "SV") "verb" else "infinitive" + if (db.tableExists("verbs") && db.columnExists("verbs", columnName)) { + db + .rawQuery( + "SELECT DISTINCT $columnName FROM verbs WHERE $columnName LIKE ? LIMIT 10", + arrayOf("$query%"), + ).use { cursor -> + val colIndex = cursor.getColumnIndex(columnName) + if (colIndex != -1) { + while (cursor.moveToNext()) { + val verb = cursor.getString(colIndex) + if (!verb.isNullOrBlank()) { + results.add(ConjugateSearchResult(verb, alias)) + } + } + } + } + } + } catch (e: android.database.sqlite.SQLiteException) { + Log.e("ConjugateViewModel", "Error searching db for $alias", e) + } catch (e: IllegalStateException) { + Log.e("ConjugateViewModel", "Error searching db for $alias", e) + } finally { + try { + db.close() + } catch (e: android.database.sqlite.SQLiteException) { + Log.e("ConjugateViewModel", "Error closing db for $alias", e) + } + } + } + + withContext(Dispatchers.Main) { + if (_searchQuery.value == query) { + _searchResults.value = results.distinctBy { it.verb.lowercase() + "_" + it.languageAlias } + } + } + } + } + + /** + * Triggers when a verb search result or recently conjugated item is clicked. + */ + fun onVerbSelected(result: ConjugateSearchResult) { + addToRecentlyConjugated(result) + clearSearchQuery() + } + + private fun addToRecentlyConjugated(item: ConjugateSearchResult) { + val currentList = _recentlyConjugated.value.toMutableList() + currentList.removeAll { it.verb.equals(item.verb, ignoreCase = true) && it.languageAlias == item.languageAlias } + currentList.add(0, item) + + val updatedList = currentList.take(15) + _recentlyConjugated.value = updatedList + saveRecentlyConjugated(updatedList) + } + + /** + * Clears all items in the recently conjugated list. + */ + fun clearAllRecentlyConjugated() { + _recentlyConjugated.value = emptyList() + prefs.edit().remove("recently_conjugated_list").apply() + } + + private fun loadRecentlyConjugated() { + val serialized = prefs.getString("recently_conjugated_list", null) ?: return + if (serialized.isBlank()) return + + val list = + serialized.split(";").mapNotNull { part -> + val tokens = part.split(",") + if (tokens.size == 2) { + ConjugateSearchResult(tokens[0], tokens[1]) + } else { + null + } + } + _recentlyConjugated.value = list + } + + private fun saveRecentlyConjugated(list: List) { + val serialized = list.joinToString(";") { "${it.verb},${it.languageAlias}" } + prefs.edit().putString("recently_conjugated_list", serialized).apply() + } +} diff --git a/app/src/test/kotlin/be/scri/ui/screens/ConjugateViewModelTest.kt b/app/src/test/kotlin/be/scri/ui/screens/ConjugateViewModelTest.kt new file mode 100644 index 00000000..ed74c2ca --- /dev/null +++ b/app/src/test/kotlin/be/scri/ui/screens/ConjugateViewModelTest.kt @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package be.scri.ui.screens + +import android.app.Application +import android.content.Context +import android.content.SharedPreferences +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ConjugateViewModelTest { + private lateinit var application: Application + private lateinit var sharedPreferences: SharedPreferences + private lateinit var sharedPreferencesEditor: SharedPreferences.Editor + + @BeforeTest + fun setUp() { + application = mockk(relaxed = true) + sharedPreferences = mockk(relaxed = true) + sharedPreferencesEditor = mockk(relaxed = true) + + every { application.getSharedPreferences("scribe_conjugate_search_prefs", Context.MODE_PRIVATE) } returns sharedPreferences + every { sharedPreferences.edit() } returns sharedPreferencesEditor + every { sharedPreferencesEditor.putString(any(), any()) } returns sharedPreferencesEditor + every { sharedPreferencesEditor.remove(any()) } returns sharedPreferencesEditor + every { sharedPreferences.getString("recently_conjugated_list", null) } returns null + } + + @Test + fun testInitialState() { + val viewModel = ConjugateViewModel(application) + assertEquals("", viewModel.searchQuery.value) + assertTrue(viewModel.searchResults.value.isEmpty()) + assertTrue(viewModel.recentlyConjugated.value.isEmpty()) + } + + @Test + fun testSearchQueryChangedToEmptyClearsResults() { + val viewModel = ConjugateViewModel(application) + viewModel.onSearchQueryChanged("test") + viewModel.onSearchQueryChanged("") + assertEquals("", viewModel.searchQuery.value) + assertTrue(viewModel.searchResults.value.isEmpty()) + } + + @Test + fun testClearSearchQuery() { + val viewModel = ConjugateViewModel(application) + viewModel.onSearchQueryChanged("test") + viewModel.clearSearchQuery() + assertEquals("", viewModel.searchQuery.value) + assertTrue(viewModel.searchResults.value.isEmpty()) + } + + @Test + fun testOnVerbSelectedAddsToRecentlyConjugatedAndClearsSearch() { + val viewModel = ConjugateViewModel(application) + val result = ConjugateSearchResult("essere", "IT") + viewModel.onSearchQueryChanged("ess") + viewModel.onVerbSelected(result) + + assertEquals("", viewModel.searchQuery.value) + assertEquals(1, viewModel.recentlyConjugated.value.size) + assertEquals(result, viewModel.recentlyConjugated.value[0]) + + verify { + sharedPreferencesEditor.putString("recently_conjugated_list", "essere,IT") + sharedPreferencesEditor.apply() + } + } + + @Test + fun testClearAllRecentlyConjugated() { + val viewModel = ConjugateViewModel(application) + val result = ConjugateSearchResult("essere", "IT") + viewModel.onVerbSelected(result) + viewModel.clearAllRecentlyConjugated() + + assertTrue(viewModel.recentlyConjugated.value.isEmpty()) + verify { + sharedPreferencesEditor.remove("recently_conjugated_list") + sharedPreferencesEditor.apply() + } + } + + @Test + fun testLoadRecentlyConjugated() { + every { sharedPreferences.getString("recently_conjugated_list", null) } returns "essere,IT;parler,FR" + + val testViewModel = ConjugateViewModel(application) + val list = testViewModel.recentlyConjugated.value + assertEquals(2, list.size) + assertEquals(ConjugateSearchResult("essere", "IT"), list[0]) + assertEquals(ConjugateSearchResult("parler", "FR"), list[1]) + } +}