implement CSV import for new words and refactor UI components to use AppCard

This commit is contained in:
jonasgaudian
2026-02-16 22:22:11 +01:00
parent 70e416d5e1
commit a7c83bb846
3 changed files with 338 additions and 75 deletions

View File

@@ -101,6 +101,7 @@ fun AppCard(
text: String? = null,
expandable: Boolean = false,
initiallyExpanded: Boolean = false,
onClick: (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
) {
var isExpanded by remember { mutableStateOf(initiallyExpanded) }
@@ -113,6 +114,7 @@ fun AppCard(
// Check if we need to render the header row
// Updated to include icon in the check
val hasHeader = title != null || text != null || expandable || icon != null
val canClickHeader = expandable || onClick != null
Surface(
modifier = modifier
@@ -133,7 +135,12 @@ fun AppCard(
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = expandable) { isExpanded = !isExpanded }
.clickable(enabled = canClickHeader) {
if (expandable) {
isExpanded = !isExpanded
}
onClick?.invoke()
}
.padding(ComponentDefaults.CardPadding),
verticalAlignment = Alignment.CenterVertically
) {
@@ -186,17 +193,27 @@ fun AppCard(
// --- Content Area ---
if (!expandable || isExpanded) {
Column(
modifier = Modifier.padding(
val contentModifier = Modifier
.padding(
start = ComponentDefaults.CardPadding,
end = ComponentDefaults.CardPadding,
bottom = ComponentDefaults.CardPadding,
// If we have a header, remove the top padding so content sits closer to the title.
// If no header (legacy behavior), keep the top padding.
top = if (hasHeader) 0.dp else ComponentDefaults.CardPadding
),
content = content
)
)
if (!hasHeader && onClick != null) {
Column(
modifier = contentModifier.clickable { onClick() },
content = content
)
} else {
Column(
modifier = contentModifier,
content = content
)
}
}
}
}

View File

@@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AddCircle
import androidx.compose.material.icons.filled.CheckCircle
@@ -25,8 +24,6 @@ import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Psychology
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.TrendingUp
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -47,6 +44,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavHostController
import eu.gaudian.translator.utils.findActivity
import eu.gaudian.translator.view.composable.AppCard
import eu.gaudian.translator.view.composable.Screen
import eu.gaudian.translator.view.vocabulary.widgets.WeeklyActivityChartWidget
import eu.gaudian.translator.viewmodel.ProgressViewModel
@@ -177,10 +175,8 @@ fun StatCard(
title: String,
subtitle: String
) {
Card(
AppCard(
modifier = modifier,
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Column(
modifier = Modifier.padding(20.dp),
@@ -208,10 +204,8 @@ fun GoalCard(
title: String,
subtitle: String
) {
Card(
AppCard(
modifier = modifier,
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Column(
modifier = Modifier.padding(20.dp),
@@ -269,19 +263,15 @@ fun ActionCard(
}
if (onClick != null) {
Card(
AppCard(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = containerColor, contentColor = contentColor),
onClick = onClick
) {
cardContent()
}
} else {
Card(
AppCard(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = containerColor, contentColor = contentColor)
) {
cardContent()
}
@@ -311,10 +301,8 @@ fun WeeklyProgressSection(
Spacer(modifier = Modifier.height(8.dp))
Card(
AppCard(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
if (weeklyActivityStats.isEmpty()) {
Column(
@@ -344,10 +332,8 @@ fun BottomStatsSection() {
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
// Total Words
Card(
AppCard(
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Column(modifier = Modifier.padding(20.dp)) {
Text(text = "TOTAL WORDS", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f))
@@ -363,10 +349,8 @@ fun BottomStatsSection() {
}
// Accuracy
Card(
AppCard(
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Column(modifier = Modifier.padding(20.dp)) {
Text(text = "ACCURACY", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f))

View File

@@ -1,5 +1,7 @@
package eu.gaudian.translator.view.vocabulary
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -22,13 +24,14 @@ import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.material.icons.filled.DriveFolderUpload
import androidx.compose.material.icons.filled.EditNote
import androidx.compose.material.icons.filled.LibraryBooks
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
@@ -36,12 +39,14 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
@@ -52,12 +57,18 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavHostController
import eu.gaudian.translator.R
import eu.gaudian.translator.model.Language
import eu.gaudian.translator.model.VocabularyItem
import eu.gaudian.translator.utils.StatusMessageId
import eu.gaudian.translator.utils.StatusMessageService
import eu.gaudian.translator.utils.findActivity
import eu.gaudian.translator.view.composable.AppButton
import eu.gaudian.translator.view.composable.AppCard
import eu.gaudian.translator.view.composable.AppOutlinedButton
import eu.gaudian.translator.view.composable.AppSlider
import eu.gaudian.translator.view.composable.AppTopAppBar
import eu.gaudian.translator.view.composable.InspiringSearchField
import eu.gaudian.translator.view.composable.SingleLanguageDropDown
import eu.gaudian.translator.view.composable.SourceLanguageDropdown
import eu.gaudian.translator.view.composable.TargetLanguageDropdown
import eu.gaudian.translator.viewmodel.LanguageViewModel
@@ -88,15 +99,122 @@ fun NewWordScreen(
}
}
val statusMessageService = StatusMessageService
val context = LocalContext.current
val showTableImportDialog = remember { mutableStateOf(false) }
var parsedTable by remember { mutableStateOf<List<List<String>>>(emptyList()) }
var selectedColFirst by remember { mutableIntStateOf(0) }
var selectedColSecond by remember { mutableIntStateOf(1) }
var skipHeader by remember { mutableStateOf(true) }
var selectedLangFirst by remember { mutableStateOf<Language?>(null) }
var selectedLangSecond by remember { mutableStateOf<Language?>(null) }
var parseError by remember { mutableStateOf<String?>(null) }
fun parseCsv(text: String): List<List<String>> {
if (text.isBlank()) return emptyList()
val candidates = listOf(',', ';', '\t')
val sampleLine = text.lineSequence().firstOrNull { it.isNotBlank() } ?: return emptyList()
val delimiter = candidates.maxBy { c -> sampleLine.count { it == c } }
val rows = mutableListOf<List<String>>()
var current = StringBuilder()
var inQuotes = false
val currentRow = mutableListOf<String>()
var i = 0
while (i < text.length) {
when (val ch = text[i]) {
'"' -> {
if (inQuotes && i + 1 < text.length && text[i + 1] == '"') {
current.append('"')
i++
} else {
inQuotes = !inQuotes
}
}
'\r' -> {
// ignore
}
'\n' -> {
val field = current.toString()
current = StringBuilder()
currentRow.add(field)
rows.add(currentRow.toList())
currentRow.clear()
inQuotes = false
}
else -> {
if (ch == delimiter && !inQuotes) {
val field = current.toString()
currentRow.add(field)
current = StringBuilder()
} else {
current.append(ch)
}
}
}
i++
}
if (current.isNotEmpty() || currentRow.isNotEmpty()) {
currentRow.add(current.toString())
rows.add(currentRow.toList())
}
return rows.map { row ->
row.map { it.trim().trim('"') }
}.filter { r -> r.any { it.isNotBlank() } }
}
val errorParsingTable = stringResource(R.string.error_parsing_table)
val errorParsingTableWithReason = stringResource(R.string.error_parsing_table_with_reason)
val importTableLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
onResult = { uri ->
uri?.let { u ->
try {
context.contentResolver.takePersistableUriPermission(u, android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
} catch (_: Exception) {}
try {
val mime = context.contentResolver.getType(u)
val isExcel = mime == "application/vnd.ms-excel" ||
mime == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
if (isExcel) {
statusMessageService.showErrorById(StatusMessageId.ERROR_EXCEL_NOT_SUPPORTED)
return@let
}
context.contentResolver.openInputStream(u)?.use { inputStream ->
val text = inputStream.bufferedReader().use { it.readText() }
val rows = parseCsv(text)
if (rows.isNotEmpty() && rows.maxOf { it.size } >= 2) {
parsedTable = rows
selectedColFirst = 0
selectedColSecond = 1.coerceAtMost(rows.first().size - 1)
showTableImportDialog.value = true
parseError = null
} else {
parseError = errorParsingTable
statusMessageService.showErrorMessage(parseError!!)
}
}
} catch (e: Exception) {
parseError = e.message
statusMessageService.showErrorMessage(
(errorParsingTableWithReason + " " + e.message)
)
}
}
}
)
Box(
modifier = modifier.fillMaxSize(),
modifier = modifier.fillMaxSize().padding(16.dp),
contentAlignment = Alignment.TopCenter
) {
Column(
modifier = Modifier
.widthIn(max = 700.dp) // Perfect scaling for tablets/foldables
.fillMaxSize()
.verticalScroll(rememberScrollState())
.verticalScroll(rememberScrollState()).padding(0.dp)
) {
AppTopAppBar(
title = "New Words",
@@ -120,7 +238,6 @@ fun NewWordScreen(
}
}
},
modifier = Modifier.padding(horizontal = 24.dp)
)
Spacer(modifier = Modifier.height(24.dp))
@@ -128,19 +245,151 @@ fun NewWordScreen(
AddManuallyCard(
languageViewModel = languageViewModel,
vocabularyViewModel = vocabularyViewModel,
modifier = Modifier.padding(horizontal = 24.dp)
)
Spacer(modifier = Modifier.height(24.dp))
BottomActionCardsRow(
modifier = Modifier.padding(horizontal = 24.dp)
onImportCsvClick = {
@Suppress("HardCodedStringLiteral")
importTableLauncher.launch(
arrayOf(
"text/csv",
"text/comma-separated-values",
"text/tab-separated-values",
"text/plain",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
)
)
}
)
// Extra padding at the bottom for scroll clearance
Spacer(modifier = Modifier.height(100.dp))
}
}
if (showTableImportDialog.value) {
AlertDialog(
onDismissRequest = { showTableImportDialog.value = false },
title = { Text(stringResource(R.string.label_import_table_csv_excel)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
val columnCount = parsedTable.maxOfOrNull { it.size } ?: 0
Row(verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.label_first_column), modifier = Modifier.weight(1f))
var menu1Expanded by remember { mutableStateOf(false) }
AppOutlinedButton(onClick = { menu1Expanded = true }) {
Text(stringResource(R.string.label_column_n, selectedColFirst + 1))
}
DropdownMenu(expanded = menu1Expanded, onDismissRequest = { menu1Expanded = false }) {
(0 until columnCount).forEach { idx ->
val header = parsedTable.firstOrNull()?.getOrNull(idx).orEmpty()
DropdownMenuItem(
text = { Text("#${idx + 1}$header") },
onClick = { selectedColFirst = idx; menu1Expanded = false }
)
}
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.label_second_column), modifier = Modifier.weight(1f))
var menu2Expanded by remember { mutableStateOf(false) }
AppOutlinedButton(onClick = { menu2Expanded = true }) {
Text(stringResource(R.string.label_column_n, selectedColSecond + 1))
}
DropdownMenu(expanded = menu2Expanded, onDismissRequest = { menu2Expanded = false }) {
(0 until columnCount).forEach { idx ->
val header = parsedTable.firstOrNull()?.getOrNull(idx).orEmpty()
DropdownMenuItem(
text = { Text("#${idx + 1}$header") },
onClick = { selectedColSecond = idx; menu2Expanded = false }
)
}
}
}
Text(stringResource(R.string.label_languages))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.label_first_language))
SingleLanguageDropDown(
languageViewModel = languageViewModel,
selectedLanguage = selectedLangFirst,
onLanguageSelected = { selectedLangFirst = it }
)
}
Column(modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.label_second_language))
SingleLanguageDropDown(
languageViewModel = languageViewModel,
selectedLanguage = selectedLangSecond,
onLanguageSelected = { selectedLangSecond = it }
)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
androidx.compose.material3.Checkbox(checked = skipHeader, onCheckedChange = { skipHeader = it })
Spacer(modifier = Modifier.width(4.dp))
Text(stringResource(R.string.label_header_row))
}
val startIdx = if (skipHeader) 1 else 0
val previewA = parsedTable.drop(startIdx).take(5).mapNotNull { it.getOrNull(selectedColFirst) }.joinToString(", ")
val previewB = parsedTable.drop(startIdx).take(5).mapNotNull { it.getOrNull(selectedColSecond) }.joinToString(", ")
Text(stringResource(R.string.label_preview_first, previewA))
Text(stringResource(R.string.label_preview_second, previewB))
val totalRows = parsedTable.drop(startIdx).count { row ->
val a = row.getOrNull(selectedColFirst).orEmpty().isNotBlank()
val b = row.getOrNull(selectedColSecond).orEmpty().isNotBlank()
a || b
}
Text(stringResource(R.string.text_rows_to_import_1d, totalRows))
}
},
confirmButton = {
val errorSelectTwoColumns = stringResource(R.string.error_select_two_columns)
val errorSelectLanguages = stringResource(R.string.error_select_languages)
val errorNoRowsToImport = stringResource(R.string.error_no_rows_to_import)
val infoImportedItemsFrom = stringResource(R.string.info_imported_items_from)
TextButton(onClick = {
if (selectedColFirst == selectedColSecond) {
statusMessageService.showErrorMessage(errorSelectTwoColumns)
return@TextButton
}
val langA = selectedLangFirst
val langB = selectedLangSecond
if (langA == null || langB == null) {
statusMessageService.showErrorMessage(errorSelectLanguages)
return@TextButton
}
val startIdx = if (skipHeader) 1 else 0
val items = parsedTable.drop(startIdx).mapNotNull { row ->
val a = row.getOrNull(selectedColFirst)?.trim().orEmpty()
val b = row.getOrNull(selectedColSecond)?.trim().orEmpty()
if (a.isBlank() && b.isBlank()) null else VocabularyItem(
id = 0,
languageFirstId = langA.nameResId,
languageSecondId = langB.nameResId,
wordFirst = a,
wordSecond = b
)
}
if (items.isEmpty()) {
statusMessageService.showErrorMessage(errorNoRowsToImport)
return@TextButton
}
vocabularyViewModel.addVocabularyItems(items)
statusMessageService.showSuccessMessage(infoImportedItemsFrom + " " + items.size)
showTableImportDialog.value = false
}) { Text(stringResource(R.string.label_import)) }
},
dismissButton = {
TextButton(onClick = { showTableImportDialog.value = false }) {
Text(stringResource(R.string.label_cancel))
}
}
)
}
}
// --- AI GENERATOR CARD (From previous implementation) ---
@@ -157,12 +406,8 @@ fun AIGeneratorCard(
modifier: Modifier = Modifier
) {
val hints = stringArrayResource(R.array.vocabulary_hints)
Card(
AppCard(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
)
) {
Column(modifier = Modifier.padding(24.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -293,12 +538,8 @@ fun AddManuallyCard(
val canAdd = wordText.isNotBlank() && translationText.isNotBlank() &&
selectedSourceLanguage != null && selectedTargetLanguage != null
Card(
AppCard(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
)
) {
Column(modifier = Modifier.padding(24.dp)) {
// Header Row
@@ -329,18 +570,6 @@ fun AddManuallyCard(
)
}
Surface(
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
shape = RoundedCornerShape(12.dp)
) {
Text(
text = languageLabel,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
)
}
}
Spacer(modifier = Modifier.height(24.dp))
@@ -378,6 +607,36 @@ fun AddManuallyCard(
singleLine = true
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.text_select_languages),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f))
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
SourceLanguageDropdown(
modifier = Modifier.weight(1f),
languageViewModel = languageViewModel,
iconEnabled = false,
noBorder = true
)
TargetLanguageDropdown(
modifier = Modifier.weight(1f),
languageViewModel = languageViewModel,
iconEnabled = false,
noBorder = true
)
}
Spacer(modifier = Modifier.height(24.dp))
// Add to List Button (Darker variant)
@@ -410,22 +669,22 @@ fun AddManuallyCard(
}
@Composable
fun BottomActionCardsRow(modifier: Modifier = Modifier) {
fun BottomActionCardsRow(
modifier: Modifier = Modifier,
onImportCsvClick: () -> Unit
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
// Explore Packs Card
Card(
AppCard(
modifier = Modifier.weight(1f).height(120.dp),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
),
onClick = { /* TODO: Navigate to Explore */ }
) {
Column(
modifier = Modifier.fillMaxSize(),
modifier = Modifier
.fillMaxSize()
.alpha(0.6f),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
@@ -446,19 +705,22 @@ fun BottomActionCardsRow(modifier: Modifier = Modifier) {
Text(
text = "Explore Packs",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = "Coming soon",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
}
}
// Import CSV Card
Card(
AppCard(
modifier = Modifier.weight(1f).height(120.dp),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
),
onClick = { /* TODO: Navigate to Import */ }
onClick = onImportCsvClick
) {
Column(
modifier = Modifier.fillMaxSize(),