Compare commits

...

27 Commits

Author SHA1 Message Date
jonasgaudian
199f5ae33f Refactor the CSV import logic into a reusable CsvImportDialog component and centralize download configurations. 2026-02-21 11:44:45 +01:00
jonasgaudian
cfd71162a0 Refactor the dictionary and corrector navigation by promoting the Corrector to a top-level destination and removing the tabbed MainDictionaryScreen. 2026-02-20 00:03:19 +01:00
jonasgaudian
c94b29073f implement conditional AI generator UI and improve "No Connection" handling 2026-02-19 22:50:25 +01:00
jonasgaudian
95dfd3c7eb implement automated translation and caching for vocabulary pack names and descriptions in ExplorePacksScreen using LibreTranslate. 2026-02-19 18:37:53 +01:00
jonasgaudian
d6a9ccf4e3 Implement a StageIndicator to visualize vocabulary learning progress and refine the VocabularyCard UI. 2026-02-19 17:47:44 +01:00
jonasgaudian
863920143d Refactor the WeeklyActivityChartWidget into an interactive smooth line chart and update vocabulary import labels. 2026-02-19 16:16:24 +01:00
jonasgaudian
15d03ef57f Update grammar info string resource naming, add hasFeatures helper to Vocabulary model, and update grammar count logic in VocabularyViewModel 2026-02-19 15:29:08 +01:00
jonasgaudian
f737657cdb refactor UI components and layout in NewWordScreen and HomeScreen using new reusable composables: AppActionCard, AppIconContainer, AppTextField, and LabeledSection. 2026-02-19 15:24:27 +01:00
jonasgaudian
b75f5f32a0 implement vocabulary packs exploration and request system 2026-02-19 13:01:55 +01:00
jonasgaudian
0f8d605df7 implement CEFR level filtering and language-based sorting in ExplorePacksScreen 2026-02-18 23:35:57 +01:00
jonasgaudian
0a202191eb implement vocabulary packs exploration and download functionality 2026-02-18 23:11:32 +01:00
jonasgaudian
d12a21909c adjust UI layout in StartExerciseScreen and disable text wrapping for action buttons in StartExerciseScreen and HomeScreen 2026-02-18 20:58:31 +01:00
jonasgaudian
37d8c2a6c5 Refactor the project structure by reorganizing exercise, category, and statistics components, and extract AppCard into a dedicated file. 2026-02-18 20:54:18 +01:00
jonasgaudian
8f42fa79ef add a bold title header and adjust padding in StatsScreen 2026-02-18 01:23:04 +01:00
jonasgaudian
9600ef84ae update DictionaryResultScreen and EtymologyResultScreen top bars, refactor CategoryDetailScreen to use AppCard, and rename chart legend components 2026-02-18 01:10:25 +01:00
jonasgaudian
c81e0886b8 implement DailyReviewScreen and add support for "due today only" exercise configuration 2026-02-18 01:01:39 +01:00
jonasgaudian
9db538bf0a update HomeScreen UI by adjusting DailyReviewCard content color and adding spacers in the top bar 2026-02-18 00:35:37 +01:00
jonasgaudian
4cd014957f Refactor BottomNavBar visibility and add Daily Review feature 2026-02-18 00:32:22 +01:00
jonasgaudian
4b572f8773 Layout issues in the Start Exercise Screen 2026-02-17 23:53:37 +01:00
jonasgaudian
c4fbfdf0ed implement category preselection in StartExerciseScreen and update navigation logic from CategoryDetailScreen 2026-02-17 23:31:28 +01:00
jonasgaudian
ebfd097bf8 refine CategoryDetailScreen UI and add scroll-to-hide header animation 2026-02-17 23:13:39 +01:00
jonasgaudian
f2a6a58c05 update application themes, remove Perplexity API provider, and implement dynamic daily goal check 2026-02-17 22:36:12 +01:00
jonasgaudian
3966901da2 Implement intelligent merging for duplicate vocabulary items 2026-02-17 22:23:12 +01:00
jonasgaudian
3c1e71d805 implement a comprehensive vocabulary export/import system with JSON support and conflict resolution 2026-02-17 22:06:14 +01:00
jonasgaudian
ff77086ab1 localize UI strings in LibraryComponents and expand German and Portuguese translations with motivational phrases and dictionary content options 2026-02-17 18:44:33 +01:00
jonasgaudian
dc4c62ef0b localize UI strings in LibraryComponents and expand German and Portuguese translations with motivational phrases and dictionary content options 2026-02-17 18:44:30 +01:00
jonasgaudian
64dcc5d0d5 localize UI strings in LibraryComponents and expand German and Portuguese translations with motivational phrases and dictionary content options 2026-02-17 17:57:25 +01:00
122 changed files with 8939 additions and 3398 deletions

View File

@@ -4,10 +4,10 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-02-16T10:13:39.492968600Z">
<DropdownSelection timestamp="2026-02-20T17:14:10.736481200Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=RFCW11ML1WV" />
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\jonas\.android\avd\Pixel_6.avd" />
</handle>
</Target>
</DropdownSelection>

View File

@@ -126,6 +126,7 @@ dependencies {
implementation(libs.androidx.room.runtime) // ADDED: Explicitly add runtime
implementation(libs.androidx.room.ktx)
implementation(libs.core.ktx)
implementation(libs.androidx.compose.runtime)
ksp(libs.room.compiler)
// Networking

View File

@@ -33,6 +33,16 @@
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View File

@@ -0,0 +1,69 @@
## Was ist ein API-Schlüssel?
Ein API-Schlüssel ist wie ein Passwort, das deiner App erlaubt, mit KI-Diensten zu kommunizieren. Du brauchst einen, um einen KI-Anbieter wie OpenAI (ChatGPT), Anthropic, Mistral oder DeepSeek zu nutzen.
## Einen API-Schlüssel bekommen
Einige Anbieter bieten eine begrenzte kostenlose Nutzung ihrer API an, die für die meisten Funktionen dieser App ausreichen sollte; es wird aber empfohlen, einen schnelleren und bezahlten Dienst zu verwenden.
### Für Cloud-Anbieter
1. Erstelle ein Konto auf der Website des Anbieters
2. Wähle einen Tarif, eine Abrechnungsoption oder eine kostenlose Stufe, falls verfügbar
3. Erstelle einen neuen Schlüssel und kopiere ihn
4. Füge ihn in diese App ein
### Für lokale KI-Server
Wenn du einen lokalen KI-Server (wie Ollama oder LM Studio) betreibst, brauchst du keinen API-Schlüssel. Füge einfach einen benutzerdefinierten Anbieter hinzu:
1. Tippe auf **„Benutzerdefinierten Anbieter hinzufügen“**
2. Gib die IP deines lokalen Servers und den Endpunkt ein
3. Tippe auf **„Verfügbarkeit prüfen“**, um die Verbindung zu testen
## Ein Modell auswählen
### Was sind Modelle?
Ein Modell ist ein bestimmtes KI-Gehirn. Verschiedene Modelle haben unterschiedliche Stärken:
- **Kleinere Modelle**: Schneller und günstiger
- **Größere Modelle**: Intelligenter, aber langsamer und teurer
Für vorkonfigurierte Anbieter sind einige Modelle bereits standardmäßig hinzugefügt und erwiesenermaßen mit dieser App kompatibel.
### Modelle hinzufügen
1. Öffne die Details eines Anbieters
2. Tippe auf **Modell hinzufügen**
3. Wähle **Nach Modellen scannen**, um verfügbare automatisch zu finden
4. Wähle die Modelle aus, die du verwenden möchtest
### Modelle Aufgaben zuweisen
Du kannst verschiedene Modelle für verschiedene Funktionen verwenden:
1. Gehe zum Tab **Aufgaben**
2. Wähle aus, welches Modell verwendet werden soll für:
- **Übersetzung**: Übersetzt Text zwischen Sprachen
- **Übungen**: Erstellt Übungsaufgaben
- **Wortschatz**: Generiert Vokabeln und Synonyme
- **Wörterbuch**: Sucht Definitionen nach
## Häufige Probleme
### „Ungültiger API-Schlüssel“
- Prüfe auf Tippfehler oder zusätzliche Leerzeichen
- Stelle sicher, dass dein Schlüssel auf der Website des Anbieters noch aktiv und gültig ist
### „Keine Modelle verfügbar“
- Stelle zuerst sicher, dass dein API-Schlüssel gültig ist
- Wenn du in einem lokalen Netzwerk bist, überprüfe, ob deine Verbindung und dein Endpunkt korrekt konfiguriert sind
### Langsame Antworten
- Probiere einen schnelleren Anbieter, möglicherweise musst du eine bezahlte Option wählen
- Verwende ein kleineres Modell (suche nach Namen mit „small“, „light“, „fast“, „nano“)
### Lokaler Server funktioniert nicht
- Stelle sicher, dass dein lokaler Server läuft
- Überprüfe, ob die URL korrekt ist
- Dein Handy und Computer müssen möglicherweise im selben WLAN sein, damit lokale Server funktionieren.

View File

@@ -0,0 +1,40 @@
## Was sind Kategorien?
Kategorien helfen dir, deinen Wortschatz in sinnvolle Gruppen zu ordnen. Du kannst sie nutzen, um Wörter nach Thema, Sprache, Lernstufe oder einem eigenen System zu sortieren, das für dich funktioniert.
## Zwei Arten von Kategorien
### Listenkategorien
Listenkategorien sind einfache Gruppierungen von Vokabeln. Du kannst Wörter einfach zu einer Liste hinzufügen und sie bleiben dort dauerhaft.
**Anwendungsfälle:**
- Wörter nach Thema gruppieren (z.B. „Essen“, „Reisen“, „Geschäftssprache“)
- Eigene Decks für bestimmte Zwecke erstellen
### Filterkategorien
Filterkategorien schließen automatisch alle Vokabeln ein, die bestimmten Kriterien entsprechen. Wörter werden dynamisch hinzugefügt oder entfernt, basierend auf den Filterregeln.
**Anwendungsfälle:**
- Nach Lernstufe filtern (z.B. „Wörter, die ich lerne“)
- Nach Sprache filtern
- Mehrere Kriterien für komplexe Filter kombinieren (z.B. „Spanische Wörter, die ich schon kenne“)
## Kategorien erstellen
1. **Tippe auf die + Schaltfläche**, um eine neue Kategorie zu erstellen
2. **Wähle den Typ** Liste oder Filter
3. **Gib einen Namen** und optional eine Beschreibung ein
4. **Lege die Regeln fest** (für Filterkategorien)
5. **Speichere** deine Kategorie
## Kategorien verwalten
- **Bearbeiten** Gehe in eine Kategorie, um ihre Einstellungen zu ändern
## Tipps
- Nutze Filterkategorien für Lernstufen, um den Fortschritt bei allen Wörtern einer bestimmten Stufe automatisch zu verfolgen.
- Dieselbe Vokabelkarte kann in mehreren Kategorien erscheinen.
- Du kannst Kategorien auch nutzen, um große Gruppen von Vokabeln auf einmal zu verwalten, indem du die „Alle auswählen“-Funktion innerhalb einer Kategorie verwendest.

View File

@@ -0,0 +1,24 @@
All vocabulary lists in this section were generated automatically. While I strive for accuracy and quality, please keep in mind that these are machine-generated collections.
I'm a single developer building and maintaining this app in my spare time. I'm passionate about creating tools that help people learn languages, but I have limited resources and time.
### Your Feedback Matters
I greatly appreciate any feedback, suggestions, or ideas you might have! If you:
- Find errors in any vocabulary pack
- Have ideas for new topics, languages, or categories
- Want to request a specific vocabulary pack
- Have suggestions for improving existing packs
Please don't hesitate to reach out through the Request feature or contact me directly. Your input helps make this app better for everyone!
### How Packs Work
- **Download** packs that interest you
- **Preview** the words before adding them
- **Import** them into your library with options to handle duplicates
- **Organize** them into categories which are created automatically
Thank you for using this app and your feedback!

View File

@@ -57,6 +57,10 @@ data class VocabularyItem(
features = switchedFeaturesJson
)
}
fun hasFeatures(): Boolean {
return !features.isNullOrBlank() && features != "{}"
}
}
@Serializable

View File

@@ -0,0 +1,261 @@
@file:OptIn(ExperimentalTime::class)
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.model
import kotlinx.serialization.Contextual
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
/**
* # Vocabulary Export/Import Data Models
*
* This file defines the data structures used for exporting and importing vocabulary data.
* The export format is designed to be:
* - **Portable**: Can be stored as JSON files, transmitted via REST APIs, shared via messaging apps
* - **Flexible**: Supports different export scopes (full repository, categories, individual items)
* - **Complete**: Preserves all data including learning stages, categories, and progress statistics
* - **Versioned**: Includes format version for future compatibility
*
* ## Export Scopes
*
* The system supports multiple export scopes via the [VocabularyExportData] sealed class:
* - [FullRepositoryExport]: Complete repository state with all items, categories, and mappings
* - [CategoryExport]: Single category with all its vocabulary items and their states
* - [ItemListExport]: Arbitrary list of vocabulary items with their associated data
* - [SingleItemExport]: Individual vocabulary item with its complete information
*
* ## Data Preservation
*
* Each export includes:
* - Vocabulary items (words, translations, features, creation dates)
* - Learning states (correct/incorrect counts, last answer timestamps)
* - Stage mappings (current learning stage for each item)
* - Categories (both manual tags and automatic filters)
* - Category memberships (which items belong to which categories)
* - Metadata (export date, format version, statistics)
*
* ## Usage Examples
*
* ### Exporting a full repository:
* ```kotlin
* val exportData = repository.exportFullRepository()
* val jsonString = Json.encodeToString(VocabularyExportData.serializer(), exportData)
* // Save to file, send via API, share via WhatsApp, etc.
* ```
*
* ### Exporting a single category:
* ```kotlin
* val exportData = repository.exportCategory(categoryId)
* val jsonString = Json.encodeToString(VocabularyExportData.serializer(), exportData)
* ```
*
* ### Importing data:
* ```kotlin
* val importData = Json.decodeFromString<VocabularyExportData>(jsonString)
* repository.importVocabularyData(importData, conflictStrategy = ConflictStrategy.MERGE)
* ```
*/
/**
* Sealed class representing different types of vocabulary exports.
* Each type contains the appropriate data for its scope.
*/
@Serializable
sealed class VocabularyExportData {
abstract val formatVersion: Int
abstract val exportDate: @Contextual Instant
abstract val metadata: ExportMetadata
}
/**
* Metadata about the export operation.
*
* @property itemCount Total number of vocabulary items included
* @property categoryCount Total number of categories included
* @property exportScope Description of what was exported
* @property appVersion Version of the app that created the export (optional)
*/
@Serializable
data class ExportMetadata(
val itemCount: Int,
val categoryCount: Int,
val exportScope: String,
val appVersion: String? = null
)
/**
* Export format for the complete repository state.
*
* This includes everything: all vocabulary items, all categories, all learning states,
* all mappings. Use this for full backups or transferring complete data between devices.
*
* @property items All vocabulary items
* @property categories All categories (tags and filters)
* @property states Learning states for all items
* @property categoryMappings Mappings between items and categories
* @property stageMappings Current learning stage for each item
*/
@Serializable
@SerialName("FullRepository")
data class FullRepositoryExport(
override val formatVersion: Int = 1,
@Contextual override val exportDate: Instant,
override val metadata: ExportMetadata,
val items: List<VocabularyItem>,
val categories: List<VocabularyCategory>,
val states: List<VocabularyItemState>,
val categoryMappings: List<CategoryMappingData>,
val stageMappings: List<StageMappingData>
) : VocabularyExportData()
/**
* Export format for a single category and its vocabulary items.
*
* Use this to share a specific vocabulary list or category with others.
* All items in the category are included with their complete learning data.
*
* @property category The category being exported
* @property items All vocabulary items belonging to this category
* @property states Learning states for the items in this category
* @property stageMappings Learning stages for the items in this category
*/
@Serializable
@SerialName("Category")
data class CategoryExport(
override val formatVersion: Int = 1,
@Contextual override val exportDate: Instant,
override val metadata: ExportMetadata,
val category: VocabularyCategory,
val items: List<VocabularyItem>,
val states: List<VocabularyItemState>,
val stageMappings: List<StageMappingData>
) : VocabularyExportData()
/**
* Export format for a custom list of vocabulary items.
*
* Use this to create custom vocabulary sets for sharing or studying specific words.
* Optionally includes category information if the items belong to specific categories.
*
* @property items The vocabulary items being exported
* @property states Learning states for these items
* @property stageMappings Learning stages for these items
* @property associatedCategories Categories that these items belong to (optional)
* @property categoryMappings Mappings between items and categories (optional)
*/
@Serializable
@SerialName("ItemList")
data class ItemListExport(
override val formatVersion: Int = 1,
@Contextual override val exportDate: Instant,
override val metadata: ExportMetadata,
val items: List<VocabularyItem>,
val states: List<VocabularyItemState>,
val stageMappings: List<StageMappingData>,
val associatedCategories: List<VocabularyCategory> = emptyList(),
val categoryMappings: List<CategoryMappingData> = emptyList()
) : VocabularyExportData()
/**
* Export format for a single vocabulary item with all its details.
*
* Use this for sharing individual words or phrases with complete context.
*
* @property item The vocabulary item being exported
* @property state Learning state for this item (if available)
* @property stage Current learning stage for this item
* @property categories Categories this item belongs to
*/
@Serializable
@SerialName("SingleItem")
data class SingleItemExport(
override val formatVersion: Int = 1,
@Contextual override val exportDate: Instant,
override val metadata: ExportMetadata,
val item: VocabularyItem,
val state: VocabularyItemState?,
val stage: VocabularyStage,
val categories: List<VocabularyCategory> = emptyList()
) : VocabularyExportData()
/**
* Simplified representation of category mapping for export/import.
*
* Maps a vocabulary item ID to a category ID. During import, IDs may be
* remapped if conflicts exist.
*/
@Serializable
data class CategoryMappingData(
val vocabularyItemId: Int,
val categoryId: Int
)
/**
* Simplified representation of stage mapping for export/import.
*
* Maps a vocabulary item ID to its current learning stage.
*/
@Serializable
data class StageMappingData(
val vocabularyItemId: Int,
val stage: VocabularyStage
)
/**
* Strategy for handling conflicts during import operations.
*
* Conflicts occur when imported data has the same IDs or content as existing data.
* Different strategies handle these conflicts in different ways.
*/
enum class ConflictStrategy {
/**
* Skip importing items that already exist (based on ID or content).
* Preserves all existing data unchanged.
*/
SKIP,
/**
* Replace existing items with imported versions.
* Overwrites local data with imported data when conflicts occur.
*/
REPLACE,
/**
* Merge imported data with existing data.
* - For vocabulary items: Keep existing if duplicate, add new ones
* - For states: Keep the more advanced learning progress
* - For categories: Merge memberships
* - For stages: Keep the higher stage
*/
MERGE,
/**
* Assign new IDs to all imported items to avoid conflicts.
* Creates duplicates rather than merging or replacing.
* Useful when importing the same data multiple times for practice.
*/
RENAME
}
/**
* Result of an import operation with statistics.
*
* @property itemsImported Number of vocabulary items successfully imported
* @property itemsSkipped Number of items skipped due to conflicts
* @property itemsUpdated Number of existing items updated
* @property categoriesImported Number of categories imported
* @property errors List of errors encountered during import (if any)
*/
data class ImportResult(
val itemsImported: Int,
val itemsSkipped: Int,
val itemsUpdated: Int,
val categoriesImported: Int,
val errors: List<String> = emptyList()
) {
val isSuccess: Boolean get() = errors.isEmpty()
val totalProcessed: Int get() = itemsImported + itemsSkipped + itemsUpdated
}

View File

@@ -10,6 +10,7 @@ import eu.gaudian.translator.model.repository.SettingsRepository
import eu.gaudian.translator.utils.ApiCallback
import eu.gaudian.translator.utils.Log
import eu.gaudian.translator.utils.StatusAction
import eu.gaudian.translator.utils.StatusMessageId
import eu.gaudian.translator.utils.StatusMessageService
import eu.gaudian.translator.viewmodel.MessageAction
import eu.gaudian.translator.viewmodel.MessageDisplayType
@@ -228,10 +229,7 @@ class ApiManager(private val context: Context) {
val allowNoKey = provider.isCustom || isLocalHost
val effectiveKey = if (key.isNotEmpty()) key else if (allowNoKey) "" else key
// Perplexity does not support listing models via /v1/models; fail fast with a clear message
if (provider.key.equals("perplexity", ignoreCase = true)) {
return Pair(emptyList(), "Perplexity does not support fetching modeles.") //TODO this must be transalted!
}
return withContext(Dispatchers.IO) {
try {
@@ -406,11 +404,7 @@ class ApiManager(private val context: Context) {
if (languageModel == null) {
val errorMsg = context.getString(R.string.no_model_selected_for_the_task, modelType.getLocalizedName(context))
StatusMessageService.trigger(StatusAction.ShowActionableMessage(
text = errorMsg,
type = MessageDisplayType.ACTIONABLE_ERROR,
action = MessageAction.NAVIGATE_TO_API_KEYS
))
StatusMessageService.showErrorById(StatusMessageId.ERROR_NO_MODEL_CONFIGURED)
callback.onFailure(errorMsg)
return@launch
}

View File

@@ -115,17 +115,6 @@ data class ApiProvider(
LanguageModel("llama-3.1-8b-instant", "Llama 3.1 8B", "groq", "Powerful Llama 3 model running at extreme speed."),
)
),
ApiProvider(
key = "perplexity",
displayName = "Perplexity",
baseUrl = "https://api.perplexity.ai/",
endpoint = "chat/completions",
websiteUrl = "https://www.perplexity.ai/",
models = listOf(
LanguageModel("sonar", "Sonar Small Online", "perplexity", "A faster online model for quick, up-to-date answers."), // default
LanguageModel("sonar-pro", "Sonar Pro", "perplexity", "Advanced search-focused model for richer context and longer answers."),
)
),
ApiProvider(
key = "xai",
displayName = "xAI Grok",

View File

@@ -1,193 +0,0 @@
package eu.gaudian.translator.model.communication
import android.content.Context
import androidx.core.content.edit
import eu.gaudian.translator.R
import eu.gaudian.translator.utils.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.create
import java.io.File
import java.io.FileOutputStream
import java.security.MessageDigest
/**
* Manages downloading files from the server, verifying checksums, and checking versions.
*/
class FileDownloadManager(private val context: Context) {
private val baseUrl = "http://23.88.48.47/"
private val retrofit = Retrofit.Builder()
.baseUrl(baseUrl)
.addConverterFactory(GsonConverterFactory.create())
.client(OkHttpClient.Builder().build())
.build()
private val manifestApiService = retrofit.create<ManifestApiService>()
@Suppress("HardCodedStringLiteral")
private val sharedPreferences = context.getSharedPreferences("file_versions", Context.MODE_PRIVATE)
/**
* Fetches the manifest from the server.
*/
suspend fun fetchManifest(): ManifestResponse? = withContext(Dispatchers.IO) {
try {
val response = manifestApiService.getManifest().execute()
if (response.isSuccessful) {
response.body()
} else {
@Suppress("HardCodedStringLiteral") val errorMessage = response.errorBody()?.string() ?: "HTTP ${response.code()}"
@Suppress("HardCodedStringLiteral")
Log.e("FileDownloadManager", "Failed to fetch manifest: $errorMessage")
throw Exception(context.getString(R.string.failed_to_fetch_manifest, errorMessage))
}
} catch (e: Exception) {
@Suppress("HardCodedStringLiteral")
Log.e("FileDownloadManager", "Error fetching manifest", e)
throw e
}
}
/**
* Downloads all assets for a file and verifies their checksums.
*/
suspend fun downloadFile(fileInfo: FileInfo, onProgress: (Float) -> Unit = {}): Boolean = withContext(Dispatchers.IO) {
val totalAssets = fileInfo.assets.size
for ((completedAssets, asset) in fileInfo.assets.withIndex()) {
val success = downloadAsset(asset) { assetProgress ->
// Calculate overall progress
val assetContribution = assetProgress / totalAssets
val previousAssetsProgress = completedAssets.toFloat() / totalAssets
onProgress(previousAssetsProgress + assetContribution)
}
if (!success) {
return@withContext false
}
}
// Save version after all assets are downloaded successfully
sharedPreferences.edit { putString(fileInfo.id, fileInfo.version) }
@Suppress("HardCodedStringLiteral")
Log.d("FileDownloadManager", "Download successful for all assets of ${fileInfo.name}")
true
}
/**
* Downloads a specific asset and verifies its checksum.
*/
private suspend fun downloadAsset(asset: Asset, onProgress: (Float) -> Unit = {}): Boolean = withContext(Dispatchers.IO) {
val fileUrl = "${baseUrl}${asset.filename}"
val localFile = File(context.filesDir, asset.filename)
try {
val client = OkHttpClient()
val request = Request.Builder().url(fileUrl).build()
val response = client.newCall(request).execute()
if (!response.isSuccessful) {
val errorMessage = context.getString(
R.string.text_download_failed_http,
response.code,
response.message
)
Log.e("FileDownloadManager", errorMessage)
throw Exception(errorMessage)
}
val body = response.body
val contentLength = body.contentLength()
if (contentLength <= 0) {
throw Exception("Invalid file size: $contentLength")
}
FileOutputStream(localFile).use { output ->
body.byteStream().use { input ->
val buffer = ByteArray(8192)
var bytesRead: Int
var totalBytesRead: Long = 0
@Suppress("HardCodedStringLiteral") val digest = MessageDigest.getInstance("SHA-256")
while (input.read(buffer).also { bytesRead = it } != -1) {
output.write(buffer, 0, bytesRead)
digest.update(buffer, 0, bytesRead)
totalBytesRead += bytesRead
onProgress((totalBytesRead.toFloat() / contentLength))
}
output.flush()
// Compute checksum
val computedChecksum = digest.digest().joinToString("") {
@Suppress("HardCodedStringLiteral")
"%02X".format(it)
}
if (computedChecksum.equals(asset.checksumSha256, ignoreCase = true)) {
@Suppress("HardCodedStringLiteral")
Log.d("FileDownloadManager", "Download successful for ${asset.filename}")
true
} else {
Log.e("FileDownloadManager",
context.getString(
R.string.text_checksum_mismatch_for_expected_got,
asset.filename,
asset.checksumSha256,
computedChecksum
))
localFile.delete() // Delete corrupted file
throw Exception("Checksum verification failed for ${asset.filename}")
}
}
}
} catch (e: Exception) {
@Suppress("HardCodedStringLiteral")
Log.e("FileDownloadManager", "Error downloading asset", e)
// Clean up partial download
if (localFile.exists()) {
localFile.delete()
}
throw e
}
}
/**
* Checks if a newer version is available for a file.
*/
fun isNewerVersionAvailable(fileInfo: FileInfo): Boolean {
val localVersion = sharedPreferences.getString(fileInfo.id, "0.0.0") ?: "0.0.0"
return compareVersions(fileInfo.version, localVersion) > 0
}
/**
* Compares two version strings (assuming semantic versioning).
*/
private fun compareVersions(version1: String, version2: String): Int {
val parts1 = version1.split(".").map { it.toIntOrNull() ?: 0 }
val parts2 = version2.split(".").map { it.toIntOrNull() ?: 0 }
for (i in 0 until maxOf(parts1.size, parts2.size)) {
val part1 = parts1.getOrElse(i) { 0 }
val part2 = parts2.getOrElse(i) { 0 }
if (part1 != part2) {
return part1.compareTo(part2)
}
}
return 0
}
/**
* Gets the local version of a file.
*/
fun getLocalVersion(fileId: String): String {
return sharedPreferences.getString(fileId, "0.0.0") ?: "0.0.0"
}
}

View File

@@ -1,17 +0,0 @@
package eu.gaudian.translator.model.communication
import retrofit2.Call
import retrofit2.http.GET
/**
* API service for fetching the manifest and downloading files.
*/
interface ManifestApiService {
/**
* Fetches the manifest from the server.
*/
@GET("manifest.json")
fun getManifest(): Call<ManifestResponse>
}

View File

@@ -0,0 +1,62 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.model.communication.files_download
/**
* Centralized configuration for all download-related URLs and paths.
* Update this file when server configuration changes.
*/
object DownloadConfig {
// ===== BASE URLS =====
/** Base URL for all polly content */
const val POLLY_BASE_URL = "http://pollyapp.b-cdn.net/"
// ===== DICTIONARIES CONFIG =====
/** Subdirectory for dictionary files on the server */
const val DICTIONARIES_SUBDIRECTORY = "dictionaries"
/** Full URL for dictionary files (baseUrl + subdirectory) */
const val DICTIONARIES_BASE_URL = "$POLLY_BASE_URL$DICTIONARIES_SUBDIRECTORY/"
/** Manifest file name for dictionaries */
const val DICTIONARIES_MANIFEST_FILE = "manifest.json"
/** Full URL for the dictionary manifest */
const val DICTIONARIES_MANIFEST_URL = "$DICTIONARIES_BASE_URL$DICTIONARIES_MANIFEST_FILE"
// ===== FLASHCARDS CONFIG =====
/** Subdirectory for flashcard/vocab files on the server */
const val FLASHCARDS_SUBDIRECTORY = "flashcards"
/** Full URL for flashcard files (baseUrl + subdirectory) */
const val FLASHCARDS_BASE_URL = "$POLLY_BASE_URL$FLASHCARDS_SUBDIRECTORY/"
/** Manifest file name for flashcards/vocab packs */
const val FLASHCARDS_MANIFEST_FILE = "vocab_manifest.json"
/** Full URL for the flashcard manifest */
const val FLASHCARDS_MANIFEST_URL = "$FLASHCARDS_BASE_URL$FLASHCARDS_MANIFEST_FILE"
// ===== LOCAL STORAGE PATHS =====
/** Local subdirectory for storing flashcard files (relative to filesDir) */
const val LOCAL_FLASHCARDS_PATH = FLASHCARDS_SUBDIRECTORY
// ===== HELPER METHODS =====
/**
* Returns the full remote URL for a dictionary asset.
* @param filename The asset filename (e.g., "dictionary_de.db")
*/
fun getDictionaryAssetUrl(filename: String): String = "$DICTIONARIES_BASE_URL$filename"
/**
* Returns the full remote URL for a flashcard asset.
* @param filename The asset filename (e.g., "2026_02_20_verbs_beginners_zh_pl_A1.json")
*/
fun getFlashcardAssetUrl(filename: String): String = "$FLASHCARDS_BASE_URL$filename"
}

View File

@@ -0,0 +1,560 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.model.communication.files_download
import android.content.Context
import androidx.core.content.edit
import eu.gaudian.translator.R
import eu.gaudian.translator.model.communication.Asset
import eu.gaudian.translator.model.communication.FileInfo
import eu.gaudian.translator.model.communication.ManifestResponse
import eu.gaudian.translator.utils.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.create
import java.io.File
import java.io.FileOutputStream
import java.security.MessageDigest
/**
* Manages downloading files from the server, verifying checksums, and checking versions.
* All URLs and paths are centralized in [DownloadConfig].
*/
class FileDownloadManager(private val context: Context) {
init {
Log.d("FileDownloadManager", "=== FileDownloadManager initialized ===")
Log.d("FileDownloadManager", "Context filesDir: ${context.filesDir.absolutePath}")
Log.d("FileDownloadManager", "Polly base URL: ${DownloadConfig.POLLY_BASE_URL}")
Log.d("FileDownloadManager", "Dictionaries URL: ${DownloadConfig.DICTIONARIES_BASE_URL}")
Log.d("FileDownloadManager", "Flashcards URL: ${DownloadConfig.FLASHCARDS_BASE_URL}")
}
// ===== Retrofit Services =====
private val dictionaryRetrofit = Retrofit.Builder()
.baseUrl(DownloadConfig.DICTIONARIES_BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.client(OkHttpClient.Builder().build())
.build()
private val manifestApiService = dictionaryRetrofit.create<ManifestApiService>()
private val flashcardRetrofit = Retrofit.Builder()
.baseUrl(DownloadConfig.POLLY_BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.client(OkHttpClient.Builder().build())
.build()
private val flashcardApiService = flashcardRetrofit.create<FlashcardApiService>()
private val sharedPreferences = context.getSharedPreferences("file_versions", Context.MODE_PRIVATE)
// ===== Dictionary Manifest =====
/**
* Fetches the manifest from the server.
*/
suspend fun fetchManifest(): ManifestResponse? = withContext(Dispatchers.IO) {
Log.d("FileDownloadManager", "=== fetchManifest() called ===")
Log.d("FileDownloadManager", "Fetching manifest from: ${DownloadConfig.DICTIONARIES_MANIFEST_URL}")
try {
val response = manifestApiService.getManifest().execute()
Log.d("FileDownloadManager", "Manifest response received - isSuccessful: ${response.isSuccessful}, code: ${response.code()}")
if (response.isSuccessful) {
val manifest = response.body()
Log.d("FileDownloadManager", "Manifest parsed successfully, files count: ${manifest?.files?.size ?: 0}")
manifest?.files?.forEach { file ->
Log.d("FileDownloadManager", " - File: ${file.id}, name: ${file.name}, version: ${file.version}, assets: ${file.assets.size}")
}
manifest
} else {
val errorMessage = response.errorBody()?.string() ?: "HTTP ${response.code()}"
Log.e("FileDownloadManager", "Failed to fetch manifest: $errorMessage")
throw Exception(context.getString(R.string.failed_to_fetch_manifest, errorMessage))
}
} catch (e: Exception) {
Log.e("FileDownloadManager", "Error fetching manifest", e)
throw e
}
}
// ===== Dictionary Downloads =====
/**
* Downloads all assets for a file and verifies their checksums.
*/
suspend fun downloadFile(fileInfo: FileInfo, onProgress: (Float) -> Unit = {}): Boolean = withContext(Dispatchers.IO) {
Log.d("FileDownloadManager", "=== downloadFile() called ===")
Log.d("FileDownloadManager", "File info - id: ${fileInfo.id}, name: ${fileInfo.name}, version: ${fileInfo.version}")
Log.d("FileDownloadManager", "Total assets to download: ${fileInfo.assets.size}")
val totalAssets = fileInfo.assets.size
for ((completedAssets, asset) in fileInfo.assets.withIndex()) {
Log.d("FileDownloadManager", "Processing asset ${completedAssets + 1}/$totalAssets: ${asset.filename}")
val success = downloadDictionaryAsset(asset) { assetProgress ->
// Calculate overall progress
val assetContribution = assetProgress / totalAssets
val previousAssetsProgress = completedAssets.toFloat() / totalAssets
onProgress(previousAssetsProgress + assetContribution)
}
if (!success) {
Log.e("FileDownloadManager", "Failed to download asset: ${asset.filename}")
return@withContext false
}
}
// Save version after all assets are downloaded successfully
sharedPreferences.edit { putString(fileInfo.id, fileInfo.version) }
Log.d("FileDownloadManager", "Download successful for all assets of ${fileInfo.name}")
Log.d("FileDownloadManager", "Saved version ${fileInfo.version} for id ${fileInfo.id}")
true
}
/**
* Downloads a specific dictionary asset and verifies its checksum.
*/
private suspend fun downloadDictionaryAsset(asset: Asset, onProgress: (Float) -> Unit = {}): Boolean = withContext(Dispatchers.IO) {
Log.d("FileDownloadManager", "=== downloadDictionaryAsset() called ===")
val fileUrl = DownloadConfig.getDictionaryAssetUrl(asset.filename)
val localFile = File(context.filesDir, asset.filename)
Log.d("FileDownloadManager", "Remote URL: $fileUrl")
Log.d("FileDownloadManager", "Local file path: ${localFile.absolutePath}")
Log.d("FileDownloadManager", "Local file parent: ${localFile.parentFile?.absolutePath}")
Log.d("FileDownloadManager", "Expected checksum: ${asset.checksumSha256}")
try {
Log.d("FileDownloadManager", "Creating HTTP request...")
val client = OkHttpClient()
val request = Request.Builder().url(fileUrl).build()
val response = client.newCall(request).execute()
Log.d("FileDownloadManager", "Response received - code: ${response.code}, message: ${response.message}")
if (!response.isSuccessful) {
val errorMessage = context.getString(
R.string.text_download_failed_http,
response.code,
response.message
)
Log.e("FileDownloadManager", errorMessage)
throw Exception(errorMessage)
}
val body = response.body
val contentLength = body.contentLength()
Log.d("FileDownloadManager", "Content length: $contentLength bytes")
if (contentLength <= 0) {
Log.e("FileDownloadManager", "Invalid file size: $contentLength")
throw Exception("Invalid file size: $contentLength")
}
Log.d("FileDownloadManager", "Starting file download to: ${localFile.absolutePath}")
FileOutputStream(localFile).use { output ->
body.byteStream().use { input ->
val buffer = ByteArray(8192)
var bytesRead: Int
var totalBytesRead: Long = 0
val digest = MessageDigest.getInstance("SHA-256")
while (input.read(buffer).also { bytesRead = it } != -1) {
output.write(buffer, 0, bytesRead)
digest.update(buffer, 0, bytesRead)
totalBytesRead += bytesRead
onProgress((totalBytesRead.toFloat() / contentLength))
}
output.flush()
// Compute checksum
val computedChecksum = digest.digest().joinToString("") { "%02X".format(it) }
Log.d("FileDownloadManager", "Download complete. Total bytes read: $totalBytesRead")
Log.d("FileDownloadManager", "Computed checksum: $computedChecksum")
Log.d("FileDownloadManager", "Expected checksum: ${asset.checksumSha256}")
if (computedChecksum.equals(asset.checksumSha256, ignoreCase = true)) {
Log.d("FileDownloadManager", "Checksum VERIFIED for ${asset.filename}")
Log.d("FileDownloadManager", "File saved successfully at: ${localFile.absolutePath}")
Log.d("FileDownloadManager", "File exists: ${localFile.exists()}, size: ${localFile.length()} bytes")
true
} else {
Log.e("FileDownloadManager", "Checksum MISMATCH for ${asset.filename}")
Log.e("FileDownloadManager",
context.getString(
R.string.text_checksum_mismatch_for_expected_got,
asset.filename,
asset.checksumSha256,
computedChecksum
))
Log.d("FileDownloadManager", "Deleting corrupted file: ${localFile.absolutePath}")
localFile.delete() // Delete corrupted file
throw Exception("Checksum verification failed for ${asset.filename}")
}
}
}
} catch (e: Exception) {
Log.e("FileDownloadManager", "Error downloading asset from $fileUrl", e)
Log.e("FileDownloadManager", "Target path was: ${localFile.absolutePath}")
// Clean up partial download
if (localFile.exists()) {
Log.d("FileDownloadManager", "Cleaning up partial download: ${localFile.absolutePath}")
localFile.delete()
}
throw e
}
}
// ===== Version Management =====
/**
* Checks if a newer version is available for a file.
*/
fun isNewerVersionAvailable(fileInfo: FileInfo): Boolean {
Log.d("FileDownloadManager", "=== isNewerVersionAvailable() called ===")
Log.d("FileDownloadManager", "Checking file: ${fileInfo.id} (${fileInfo.name})")
val localVersion = sharedPreferences.getString(fileInfo.id, "0.0.0") ?: "0.0.0"
Log.d("FileDownloadManager", "Local version: $localVersion, Server version: ${fileInfo.version}")
val result = compareVersions(fileInfo.version, localVersion) > 0
Log.d("FileDownloadManager", "Newer version available: $result")
return result
}
/**
* Compares two version strings (assuming semantic versioning).
*/
private fun compareVersions(version1: String, version2: String): Int {
Log.d("FileDownloadManager", "Comparing versions: '$version1' vs '$version2'")
val parts1 = version1.split(".").map { it.toIntOrNull() ?: 0 }
val parts2 = version2.split(".").map { it.toIntOrNull() ?: 0 }
for (i in 0 until maxOf(parts1.size, parts2.size)) {
val part1 = parts1.getOrElse(i) { 0 }
val part2 = parts2.getOrElse(i) { 0 }
if (part1 != part2) {
val result = part1.compareTo(part2)
Log.d("FileDownloadManager", "Version comparison result: $result (at part $i: $part1 vs $part2)")
return result
}
}
Log.d("FileDownloadManager", "Versions are equal, returning 0")
return 0
}
/**
* Gets the local version of a file.
*/
fun getLocalVersion(fileId: String): String {
val version = sharedPreferences.getString(fileId, "0.0.0") ?: "0.0.0"
Log.d("FileDownloadManager", "getLocalVersion($fileId) = $version")
return version
}
// ===== Flashcard Collections =====
/**
* Downloads a flashcard collection file with checksum verification.
*/
suspend fun downloadFlashcardCollection(
flashcardInfo: FlashcardCollectionInfo,
onProgress: (Float) -> Unit = {}
): Boolean = withContext(Dispatchers.IO) {
Log.d("FileDownloadManager", "=== downloadFlashcardCollection() called ===")
Log.d("FileDownloadManager", "Flashcard info - id: ${flashcardInfo.id}, version: ${flashcardInfo.version}")
val asset = flashcardInfo.asset
val fileUrl = DownloadConfig.getFlashcardAssetUrl(asset.filename)
val localFile = File(context.filesDir, "${DownloadConfig.LOCAL_FLASHCARDS_PATH}/${asset.filename}")
Log.d("FileDownloadManager", "Remote URL: $fileUrl")
Log.d("FileDownloadManager", "Local file path: ${localFile.absolutePath}")
Log.d("FileDownloadManager", "Local file parent: ${localFile.parentFile?.absolutePath}")
Log.d("FileDownloadManager", "Expected checksum: ${asset.checksumSha256}")
// Create subdirectory if it doesn't exist
val parentDir = localFile.parentFile
if (parentDir != null && !parentDir.exists()) {
Log.d("FileDownloadManager", "Creating subdirectory: ${parentDir.absolutePath}")
val created = parentDir.mkdirs()
Log.d("FileDownloadManager", "Subdirectory created: $created")
}
try {
Log.d("FileDownloadManager", "Creating HTTP request for flashcard...")
val client = OkHttpClient()
val request = Request.Builder().url(fileUrl).build()
val response = client.newCall(request).execute()
Log.d("FileDownloadManager", "Response received - code: ${response.code}, message: ${response.message}")
if (!response.isSuccessful) {
val errorMessage = context.getString(
R.string.text_download_failed_http,
response.code,
response.message
)
Log.e("FileDownloadManager", errorMessage)
throw Exception(errorMessage)
}
val body = response.body
val contentLength = body.contentLength()
Log.d("FileDownloadManager", "Content length: $contentLength bytes")
if (contentLength <= 0) {
Log.e("FileDownloadManager", "Invalid file size: $contentLength")
throw Exception("Invalid file size: $contentLength")
}
Log.d("FileDownloadManager", "Starting flashcard download to: ${localFile.absolutePath}")
FileOutputStream(localFile).use { output ->
body.byteStream().use { input ->
val buffer = ByteArray(8192)
var bytesRead: Int
var totalBytesRead: Long = 0
val digest = MessageDigest.getInstance("SHA-256")
while (input.read(buffer).also { bytesRead = it } != -1) {
output.write(buffer, 0, bytesRead)
digest.update(buffer, 0, bytesRead)
totalBytesRead += bytesRead
onProgress(totalBytesRead.toFloat() / contentLength)
}
output.flush()
val computedChecksum = digest.digest().joinToString("") { "%02X".format(it) }
Log.d("FileDownloadManager", "Download complete. Total bytes read: $totalBytesRead")
Log.d("FileDownloadManager", "Computed checksum: $computedChecksum")
Log.d("FileDownloadManager", "Expected checksum: ${asset.checksumSha256}")
if (computedChecksum.equals(asset.checksumSha256, ignoreCase = true)) {
Log.d("FileDownloadManager", "Checksum VERIFIED for flashcard ${asset.filename}")
Log.d("FileDownloadManager", "File saved successfully at: ${localFile.absolutePath}")
Log.d("FileDownloadManager", "File exists: ${localFile.exists()}, size: ${localFile.length()} bytes")
// Save version
sharedPreferences.edit { putString(flashcardInfo.id, flashcardInfo.version) }
Log.d("FileDownloadManager", "Saved version ${flashcardInfo.version} for id ${flashcardInfo.id}")
true
} else {
Log.e("FileDownloadManager", "Checksum MISMATCH for flashcard ${asset.filename}")
Log.e("FileDownloadManager", context.getString(
R.string.text_checksum_mismatch_for_expected_got,
asset.filename,
asset.checksumSha256,
computedChecksum
))
Log.d("FileDownloadManager", "Deleting corrupted file: ${localFile.absolutePath}")
localFile.delete()
throw Exception("Checksum verification failed for ${asset.filename}")
}
}
}
} catch (e: Exception) {
Log.e("FileDownloadManager", "Error downloading flashcard collection from $fileUrl", e)
Log.e("FileDownloadManager", "Target path was: ${localFile.absolutePath}")
if (localFile.exists()) {
Log.d("FileDownloadManager", "Cleaning up partial download: ${localFile.absolutePath}")
localFile.delete()
}
throw e
}
}
/**
* Checks if a newer version is available for a flashcard collection.
*/
fun isNewerFlashcardVersionAvailable(flashcardInfo: FlashcardCollectionInfo): Boolean {
Log.d("FileDownloadManager", "=== isNewerFlashcardVersionAvailable() called ===")
Log.d("FileDownloadManager", "Checking flashcard: ${flashcardInfo.id}")
val localVersion = sharedPreferences.getString(flashcardInfo.id, "0.0.0") ?: "0.0.0"
Log.d("FileDownloadManager", "Local version: $localVersion, Server version: ${flashcardInfo.version}")
val result = compareVersions(flashcardInfo.version, localVersion) > 0
Log.d("FileDownloadManager", "Newer version available: $result")
return result
}
/**
* Gets the local version of a flashcard collection.
*/
fun getFlashcardLocalVersion(collectionId: String): String {
val version = sharedPreferences.getString(collectionId, "0.0.0") ?: "0.0.0"
Log.d("FileDownloadManager", "getFlashcardLocalVersion($collectionId) = $version")
return version
}
// ===== Vocab Packs =====
/**
* Fetches the vocabulary-pack manifest (vocab_manifest.json).
* Unwraps the top-level [VocabManifestResponse] and returns the `lists` array.
*/
suspend fun fetchVocabManifest(): List<VocabCollectionInfo>? = withContext(Dispatchers.IO) {
Log.d("FileDownloadManager", "=== fetchVocabManifest() called ===")
Log.d("FileDownloadManager", "Fetching vocab manifest from: ${DownloadConfig.FLASHCARDS_MANIFEST_URL}")
try {
val response = flashcardApiService.getVocabManifest().execute()
Log.d("FileDownloadManager", "Vocab manifest response - isSuccessful: ${response.isSuccessful}, code: ${response.code()}")
if (response.isSuccessful) {
val manifest = response.body()
val lists = manifest?.lists
Log.d("FileDownloadManager", "Vocab manifest parsed successfully, lists count: ${lists?.size ?: 0}")
lists?.forEach { list ->
Log.d("FileDownloadManager", " - Vocab list: ${list.id}, name: ${list.name}, version: ${list.version}, filename: ${list.filename}")
}
lists
} else {
val errorMessage = response.errorBody()?.string() ?: "HTTP ${response.code()}"
Log.e("FileDownloadManager", "Failed to fetch vocab manifest: $errorMessage")
throw Exception(context.getString(R.string.failed_to_fetch_manifest, errorMessage))
}
} catch (e: Exception) {
Log.e("FileDownloadManager", "Error fetching vocab manifest", e)
throw e
}
}
/**
* Downloads a single vocabulary pack file and verifies its SHA-256 checksum.
* The file is stored at [filesDir]/[DownloadConfig.LOCAL_FLASHCARDS_PATH]/[filename].
*
* @return true on success, false (or throws) on failure.
*/
suspend fun downloadVocabCollection(
info: VocabCollectionInfo,
onProgress: (Float) -> Unit = {}
): Boolean = withContext(Dispatchers.IO) {
Log.d("FileDownloadManager", "=== downloadVocabCollection() called ===")
Log.d("FileDownloadManager", "Vocab info - id: ${info.id}, name: ${info.name}, version: ${info.version}")
Log.d("FileDownloadManager", "Vocab filename: ${info.filename}, size: ${info.sizeBytes} bytes")
val fileUrl = DownloadConfig.getFlashcardAssetUrl(info.filename)
val localFile = File(context.filesDir, "${DownloadConfig.LOCAL_FLASHCARDS_PATH}/${info.filename}")
Log.d("FileDownloadManager", "Remote URL: $fileUrl")
Log.d("FileDownloadManager", "Local file path: ${localFile.absolutePath}")
Log.d("FileDownloadManager", "Local file parent: ${localFile.parentFile?.absolutePath}")
Log.d("FileDownloadManager", "Expected checksum: ${info.checksumSha256}")
// Create subdirectory if it doesn't exist
val parentDir = localFile.parentFile
if (parentDir != null && !parentDir.exists()) {
Log.d("FileDownloadManager", "Creating subdirectory: ${parentDir.absolutePath}")
val created = parentDir.mkdirs()
Log.d("FileDownloadManager", "Subdirectory created: $created")
}
try {
Log.d("FileDownloadManager", "Creating HTTP request for vocab pack...")
val client = OkHttpClient()
val request = Request.Builder().url(fileUrl).build()
val response = client.newCall(request).execute()
Log.d("FileDownloadManager", "Response received - code: ${response.code}, message: ${response.message}")
if (!response.isSuccessful) {
val errorMessage = context.getString(
R.string.text_download_failed_http,
response.code,
response.message
)
Log.e("FileDownloadManager", errorMessage)
throw Exception(errorMessage)
}
val body = response.body
val contentLength = body.contentLength().takeIf { it > 0 } ?: info.sizeBytes
Log.d("FileDownloadManager", "Content length from header: ${body.contentLength()}, using: $contentLength bytes")
Log.d("FileDownloadManager", "Starting vocab pack download to: ${localFile.absolutePath}")
FileOutputStream(localFile).use { output ->
body.byteStream().use { input ->
val buffer = ByteArray(8192)
var bytesRead: Int
var totalBytesRead: Long = 0
val digest = MessageDigest.getInstance("SHA-256")
while (input.read(buffer).also { bytesRead = it } != -1) {
output.write(buffer, 0, bytesRead)
digest.update(buffer, 0, bytesRead)
totalBytesRead += bytesRead
if (contentLength > 0) onProgress(totalBytesRead.toFloat() / contentLength)
}
output.flush()
val computedChecksum = digest.digest().joinToString("") { "%02X".format(it) }
Log.d("FileDownloadManager", "Download complete. Total bytes read: $totalBytesRead")
Log.d("FileDownloadManager", "Computed checksum: $computedChecksum")
Log.d("FileDownloadManager", "Expected checksum: ${info.checksumSha256}")
if (computedChecksum.equals(info.checksumSha256, ignoreCase = true)) {
Log.d("FileDownloadManager", "Checksum VERIFIED for vocab pack ${info.filename}")
Log.d("FileDownloadManager", "File saved successfully at: ${localFile.absolutePath}")
Log.d("FileDownloadManager", "File exists: ${localFile.exists()}, size: ${localFile.length()} bytes")
sharedPreferences.edit(commit = true) {
putString("vocab_${info.id}", info.version.toString())
}
Log.d("FileDownloadManager", "Saved version ${info.version} for vocab_${info.id}")
true
} else {
Log.e("FileDownloadManager", "Checksum MISMATCH for vocab pack ${info.filename}")
Log.e("FileDownloadManager",
context.getString(
R.string.text_checksum_mismatch_for_expected_got,
info.filename,
info.checksumSha256,
computedChecksum
)
)
Log.d("FileDownloadManager", "Deleting corrupted file: ${localFile.absolutePath}")
localFile.delete()
throw Exception("Checksum verification failed for ${info.filename}")
}
}
}
} catch (e: Exception) {
Log.e("FileDownloadManager", "Error downloading vocab pack from $fileUrl", e)
Log.e("FileDownloadManager", "Target path was: ${localFile.absolutePath}")
if (localFile.exists()) {
Log.d("FileDownloadManager", "Cleaning up partial download: ${localFile.absolutePath}")
localFile.delete()
}
throw e
}
}
/** Returns true if the local file for this collection exists. */
fun isVocabCollectionDownloaded(info: VocabCollectionInfo): Boolean {
val localFile = File(context.filesDir, "${DownloadConfig.LOCAL_FLASHCARDS_PATH}/${info.filename}")
val exists = localFile.exists()
Log.d("FileDownloadManager", "isVocabCollectionDownloaded(${info.id}): $exists (path: ${localFile.absolutePath})")
return exists
}
/** Returns true if the server version is newer than the locally saved version. */
fun isNewerVocabVersionAvailable(info: VocabCollectionInfo): Boolean {
Log.d("FileDownloadManager", "=== isNewerVocabVersionAvailable() called ===")
Log.d("FileDownloadManager", "Checking vocab: ${info.id} (${info.name})")
val localVersion = sharedPreferences.getString("vocab_${info.id}", "0") ?: "0"
val serverVersion = info.version.toString().toIntOrNull() ?: 0
val localVersionInt = localVersion.toIntOrNull() ?: 0
Log.d("FileDownloadManager", "Local version: $localVersionInt, Server version: $serverVersion")
val result = serverVersion > localVersionInt
Log.d("FileDownloadManager", "Newer version available: $result")
return result
}
/** Returns the locally saved version number string for a vocab pack (default "0"). */
fun getVocabLocalVersion(packId: String): String {
val version = sharedPreferences.getString("vocab_$packId", "0") ?: "0"
Log.d("FileDownloadManager", "getVocabLocalVersion($packId) = $version")
return version
}
}

View File

@@ -0,0 +1,18 @@
package eu.gaudian.translator.model.communication.files_download
import retrofit2.Call
import retrofit2.http.GET
import retrofit2.http.Url
/**
* API service for flashcard / vocabulary-pack downloads.
* Base URL should be set to DownloadConfig.POLLY_BASE_URL
*/
interface FlashcardApiService {
/**
* Fetches the vocab manifest using the full URL from DownloadConfig.
*/
@GET
fun getVocabManifest(@Url url: String = DownloadConfig.FLASHCARDS_MANIFEST_URL): Call<VocabManifestResponse>
}

View File

@@ -0,0 +1,68 @@
package eu.gaudian.translator.model.communication.files_download
import com.google.gson.annotations.SerializedName
// ---------------------------------------------------------------------------
// New: vocab_manifest.json schema
// ---------------------------------------------------------------------------
/**
* Top-level wrapper returned by vocab_manifest.json.
*
* {
* "manifest_version": "1.0",
* "updated_at": "…",
* "lists": [ … ]
* }
*/
data class VocabManifestResponse(
@SerializedName("manifest_version") val manifestVersion: String = "",
@SerializedName("updated_at") val updatedAt: String = "",
@SerializedName("lists") val lists: List<VocabCollectionInfo> = emptyList(),
)
/**
* One entry inside the `lists` array of vocab_manifest.json.
*/
data class VocabCollectionInfo(
@SerializedName("id") val id: String,
@SerializedName("name") val name: String,
@SerializedName("description") val description: String,
@SerializedName("filename") val filename: String,
/** [lang_first_id, lang_second_id] matching Language IDs in the app */
@SerializedName("language_ids") val languageIds: List<Int>,
@SerializedName("category") val category: String,
@SerializedName("item_count") val itemCount: Int,
@SerializedName("emoji") val emoji: String,
@SerializedName("version") val version: Int,
/** CEFR difficulty level: A1, A2, B1, B2, C1, C2 (empty string if not set) */
@SerializedName("level") val level: String = "",
@SerializedName("size_bytes") val sizeBytes: Long,
@SerializedName("checksum_sha256") val checksumSha256: String,
@SerializedName("created_at") val createdAt: String,
@SerializedName("updated_at") val updatedAt: String,
)
// ---------------------------------------------------------------------------
// Legacy models (kept for backward compatibility with the old manifest.json
// dictionary download path)
// ---------------------------------------------------------------------------
data class FlashcardManifestResponse(
@SerializedName("collections")
val collections: List<FlashcardCollectionInfo>
)
data class FlashcardCollectionInfo(
@SerializedName("id") val id: String,
@SerializedName("name") val name: String,
@SerializedName("description") val description: String,
@SerializedName("version") val version: String,
@SerializedName("asset") val asset: FlashcardAsset
)
data class FlashcardAsset(
@SerializedName("filename") val filename: String,
@SerializedName("size_bytes") val sizeBytes: Long,
@SerializedName("checksum_sha256") val checksumSha256: String
)

View File

@@ -0,0 +1,20 @@
package eu.gaudian.translator.model.communication.files_download
import eu.gaudian.translator.model.communication.ManifestResponse
import retrofit2.Call
import retrofit2.http.GET
import retrofit2.http.Url
/**
* API service for fetching the dictionary manifest.
* Base URL should be set to DownloadConfig.DICTIONARIES_BASE_URL
*/
interface ManifestApiService {
/**
* Fetches the manifest from the server using the full URL.
*/
@GET
fun getManifest(@Url url: String = DownloadConfig.DICTIONARIES_MANIFEST_URL): Call<ManifestResponse>
}

View File

@@ -6,9 +6,9 @@ import android.content.Context
import androidx.core.content.edit
import eu.gaudian.translator.R
import eu.gaudian.translator.model.communication.Asset
import eu.gaudian.translator.model.communication.FileDownloadManager
import eu.gaudian.translator.model.communication.FileInfo
import eu.gaudian.translator.model.communication.ManifestResponse
import eu.gaudian.translator.model.communication.files_download.FileDownloadManager
import eu.gaudian.translator.utils.Log
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow

View File

@@ -5,9 +5,19 @@ package eu.gaudian.translator.model.repository
import android.content.Context
import androidx.room.withTransaction
import eu.gaudian.translator.model.CategoryExport
import eu.gaudian.translator.model.CategoryMappingData
import eu.gaudian.translator.model.ConflictStrategy
import eu.gaudian.translator.model.ExportMetadata
import eu.gaudian.translator.model.FullRepositoryExport
import eu.gaudian.translator.model.ImportResult
import eu.gaudian.translator.model.ItemListExport
import eu.gaudian.translator.model.Language
import eu.gaudian.translator.model.SingleItemExport
import eu.gaudian.translator.model.StageMappingData
import eu.gaudian.translator.model.TagCategory
import eu.gaudian.translator.model.VocabularyCategory
import eu.gaudian.translator.model.VocabularyExportData
import eu.gaudian.translator.model.VocabularyFilter
import eu.gaudian.translator.model.VocabularyItem
import eu.gaudian.translator.model.VocabularyItemState
@@ -45,6 +55,7 @@ import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlin.math.max
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
@@ -625,7 +636,7 @@ class VocabularyRepository private constructor(context: Context) {
suspend fun isTargetMetForDate(date: LocalDate): Boolean {
val dailyCorrectCount = getDailyCorrectCount(date)
val target = 10 // TODO: Replace with settingsRepository.dailyGoal.flow.first()
val target = settingsRepository.dailyGoal.flow.first()
return dailyCorrectCount >= target
}
@@ -796,6 +807,594 @@ class VocabularyRepository private constructor(context: Context) {
}
Log.d(TAG, "--- END REPOSITORY STATE ---")
}
// ==================== EXPORT/IMPORT FUNCTIONS ====================
/**
* Exports the complete repository state including all vocabulary items, categories,
* learning states, and mappings.
*
* This creates a full backup that can be used to restore the complete state on another
* device or after data loss.
*
* @return [FullRepositoryExport] containing all repository data
*
* @see importVocabularyData for importing the exported data
* @see exportToJson for converting to JSON string
*/
suspend fun exportFullRepository(): FullRepositoryExport {
Log.i(TAG, "exportFullRepository: Creating full repository export")
val items = getAllVocabularyItems()
val categories = getAllCategories()
val states = getAllVocabularyItemStates()
val categoryMappings = getCategoryMappings()
val stageMapping = loadStageMapping().first()
return FullRepositoryExport(
formatVersion = 1,
exportDate = Clock.System.now(),
metadata = ExportMetadata(
itemCount = items.size,
categoryCount = categories.size,
exportScope = "Full Repository"
),
items = items,
categories = categories,
states = states,
categoryMappings = categoryMappings.map { CategoryMappingData(it.vocabularyItemId, it.categoryId) },
stageMappings = stageMapping.map { StageMappingData(it.key, it.value) }
).also {
Log.i(TAG, "exportFullRepository: Export complete. Items: ${items.size}, Categories: ${categories.size}")
}
}
/**
* Exports a single category with all its vocabulary items and associated data.
*
* @param categoryId The ID of the category to export
* @return [CategoryExport] containing the category and its items, or null if category not found
*
* @see importVocabularyData for importing the exported data
*/
suspend fun exportCategory(categoryId: Int): CategoryExport? {
Log.i(TAG, "exportCategory: Exporting category id=$categoryId")
val category = getCategoryById(categoryId) ?: run {
Log.w(TAG, "exportCategory: Category id=$categoryId not found")
return null
}
val items = getVocabularyItemsByCategory(categoryId)
val itemIds = items.map { it.id }
val states = getAllVocabularyItemStates().filter { it.vocabularyItemId in itemIds }
val stageMapping = loadStageMapping().first().filterKeys { it in itemIds }
return CategoryExport(
formatVersion = 1,
exportDate = Clock.System.now(),
metadata = ExportMetadata(
itemCount = items.size,
categoryCount = 1,
exportScope = "Category: ${category.name}"
),
category = category,
items = items,
states = states,
stageMappings = stageMapping.map { StageMappingData(it.key, it.value) }
).also {
Log.i(TAG, "exportCategory: Export complete. Category: ${category.name}, Items: ${items.size}")
}
}
/**
* Exports a list of vocabulary items by their IDs.
*
* @param itemIds List of vocabulary item IDs to export
* @param includeCategories Whether to include category information for these items
* @return [ItemListExport] containing the items and their data
*
* @see importVocabularyData for importing the exported data
*/
suspend fun exportItemList(itemIds: List<Int>, includeCategories: Boolean = true): ItemListExport {
Log.i(TAG, "exportItemList: Exporting ${itemIds.size} items")
val items = itemDao.getItemsByIds(itemIds)
val states = getAllVocabularyItemStates().filter { it.vocabularyItemId in itemIds }
val stageMapping = loadStageMapping().first().filterKeys { it in itemIds }
val associatedCategories = if (includeCategories) {
val mappings = getCategoryMappings().filter { it.vocabularyItemId in itemIds }
val categoryIds = mappings.map { it.categoryId }.distinct()
getAllCategories().filter { it.id in categoryIds }
} else {
emptyList()
}
val categoryMappings = if (includeCategories) {
getCategoryMappings().filter { it.vocabularyItemId in itemIds }
.map { CategoryMappingData(it.vocabularyItemId, it.categoryId) }
} else {
emptyList()
}
return ItemListExport(
formatVersion = 1,
exportDate = Clock.System.now(),
metadata = ExportMetadata(
itemCount = items.size,
categoryCount = associatedCategories.size,
exportScope = "Item List (${items.size} items)"
),
items = items,
states = states,
stageMappings = stageMapping.map { StageMappingData(it.key, it.value) },
associatedCategories = associatedCategories,
categoryMappings = categoryMappings
).also {
Log.i(TAG, "exportItemList: Export complete. Items: ${items.size}")
}
}
/**
* Exports a single vocabulary item with all its details.
*
* @param itemId The ID of the vocabulary item to export
* @return [SingleItemExport] containing the item and its data, or null if item not found
*
* @see importVocabularyData for importing the exported data
*/
suspend fun exportSingleItem(itemId: Int): SingleItemExport? {
Log.i(TAG, "exportSingleItem: Exporting item id=$itemId")
val item = getVocabularyItemById(itemId) ?: run {
Log.w(TAG, "exportSingleItem: Item id=$itemId not found")
return null
}
val state = getVocabularyItemStateById(itemId)
val stage = loadStageMapping().first()[itemId] ?: VocabularyStage.NEW
val mappings = getCategoryMappings().filter { it.vocabularyItemId == itemId }
val categoryIds = mappings.map { it.categoryId }
val categories = getAllCategories().filter { it.id in categoryIds }
return SingleItemExport(
formatVersion = 1,
exportDate = Clock.System.now(),
metadata = ExportMetadata(
itemCount = 1,
categoryCount = categories.size,
exportScope = "Single Item: ${item.wordFirst}"
),
item = item,
state = state,
stage = stage,
categories = categories
).also {
Log.i(TAG, "exportSingleItem: Export complete. Item: ${item.wordFirst}")
}
}
/**
* Converts any [VocabularyExportData] to a JSON string.
*
* The resulting JSON can be:
* - Saved to a file
* - Sent via REST API
* - Shared through messaging apps (WhatsApp, Telegram, etc.)
* - Stored in cloud storage (Google Drive, Dropbox, etc.)
* - Transmitted via any text-based protocol
*
* @param exportData The export data to convert
* @param prettyPrint Whether to format the JSON for human readability (default: false)
* @return JSON string representation of the export data
*
* @see importFromJson for parsing JSON back into export data
*/
fun exportToJson(exportData: VocabularyExportData, prettyPrint: Boolean = false): String {
val json = Json {
ignoreUnknownKeys = true
this.prettyPrint = prettyPrint
}
return json.encodeToString(VocabularyExportData.serializer(), exportData)
}
/**
* Parses a JSON string into [VocabularyExportData].
*
* @param jsonString The JSON string to parse
* @return Parsed export data
* @throws kotlinx.serialization.SerializationException if JSON is invalid
*
* @see exportToJson for converting export data to JSON
* @see importVocabularyData for importing the parsed data
*/
fun importFromJson(jsonString: String): VocabularyExportData {
val json = Json { ignoreUnknownKeys = true }
return json.decodeFromString(VocabularyExportData.serializer(), jsonString)
}
/**
* Imports vocabulary data from an export.
*
* This function handles different export types (full repository, category, item list, single item)
* and applies the specified conflict resolution strategy.
*
* @param exportData The export data to import
* @param strategy The conflict resolution strategy to use (default: MERGE)
* @return [ImportResult] with statistics about the import operation
*
* @see ConflictStrategy for available strategies
* @see exportFullRepository, exportCategory, exportItemList, exportSingleItem for creating exports
*/
suspend fun importVocabularyData(
exportData: VocabularyExportData,
strategy: ConflictStrategy = ConflictStrategy.MERGE
): ImportResult {
Log.i(TAG, "importVocabularyData: Starting import with strategy=$strategy, scope=${exportData.metadata.exportScope}")
return when (exportData) {
is FullRepositoryExport -> importFullRepository(exportData, strategy)
is CategoryExport -> importCategory(exportData, strategy)
is ItemListExport -> importItemList(exportData, strategy)
is SingleItemExport -> importSingleItem(exportData, strategy)
}.also { result ->
Log.i(TAG, "importVocabularyData: Import complete. Imported: ${result.itemsImported}, " +
"Skipped: ${result.itemsSkipped}, Updated: ${result.itemsUpdated}, " +
"Categories: ${result.categoriesImported}, Errors: ${result.errors.size}")
}
}
/**
* Internal function to import a full repository export.
*/
private suspend fun importFullRepository(
export: FullRepositoryExport,
strategy: ConflictStrategy
): ImportResult {
val errors = mutableListOf<String>()
var itemsImported = 0
var itemsSkipped = 0
var itemsUpdated = 0
var categoriesImported = 0
try {
db.withTransaction {
// Import categories first (they're referenced by items)
val categoryIdMap = importCategories(export.categories, strategy)
categoriesImported = categoryIdMap.size
// Import items
val itemIdMap = importItems(export.items, export.states, export.stageMappings, strategy)
itemsImported = itemIdMap.count { it.value >= 0 }
itemsSkipped = itemIdMap.count { it.value == -1 }
// Import category mappings with remapped IDs
importCategoryMappings(export.categoryMappings, itemIdMap, categoryIdMap)
}
requestUpdateMappings()
} catch (e: Exception) {
Log.e(TAG, "importFullRepository: Error during import", e)
errors.add("Import failed: ${e.message}")
}
return ImportResult(itemsImported, itemsSkipped, itemsUpdated, categoriesImported, errors)
}
/**
* Internal function to import a category export.
*/
private suspend fun importCategory(
export: CategoryExport,
strategy: ConflictStrategy
): ImportResult {
val errors = mutableListOf<String>()
var itemsImported = 0
var itemsSkipped = 0
var itemsUpdated = 0
var categoriesImported = 0
try {
db.withTransaction {
// Import the category
val categoryIdMap = importCategories(listOf(export.category), strategy)
categoriesImported = categoryIdMap.size
val newCategoryId = categoryIdMap[export.category.id] ?: export.category.id
// Import items
val itemIdMap = importItems(export.items, export.states, export.stageMappings, strategy)
itemsImported = itemIdMap.count { it.value >= 0 }
itemsSkipped = itemIdMap.count { it.value == -1 }
// Create category mappings for all imported items
val mappings = itemIdMap.filter { it.value >= 0 }.map { (oldId, newId) ->
CategoryMappingData(newId, newCategoryId)
}
importCategoryMappings(mappings, mapOf(), mapOf())
}
requestUpdateMappings()
} catch (e: Exception) {
Log.e(TAG, "importCategory: Error during import", e)
errors.add("Import failed: ${e.message}")
}
return ImportResult(itemsImported, itemsSkipped, itemsUpdated, categoriesImported, errors)
}
/**
* Internal function to import an item list export.
*/
private suspend fun importItemList(
export: ItemListExport,
strategy: ConflictStrategy
): ImportResult {
val errors = mutableListOf<String>()
var itemsImported = 0
var itemsSkipped = 0
var itemsUpdated = 0
var categoriesImported = 0
try {
db.withTransaction {
// Import associated categories if present
val categoryIdMap = if (export.associatedCategories.isNotEmpty()) {
importCategories(export.associatedCategories, strategy).also {
categoriesImported = it.size
}
} else {
emptyMap()
}
// Import items
val itemIdMap = importItems(export.items, export.states, export.stageMappings, strategy)
itemsImported = itemIdMap.count { it.value >= 0 }
itemsSkipped = itemIdMap.count { it.value == -1 }
// Import category mappings if present
if (export.categoryMappings.isNotEmpty()) {
importCategoryMappings(export.categoryMappings, itemIdMap, categoryIdMap)
}
}
requestUpdateMappings()
} catch (e: Exception) {
Log.e(TAG, "importItemList: Error during import", e)
errors.add("Import failed: ${e.message}")
}
return ImportResult(itemsImported, itemsSkipped, itemsUpdated, categoriesImported, errors)
}
/**
* Internal function to import a single item export.
*/
private suspend fun importSingleItem(
export: SingleItemExport,
strategy: ConflictStrategy
): ImportResult {
val errors = mutableListOf<String>()
var itemsImported = 0
var itemsSkipped = 0
var itemsUpdated = 0
var categoriesImported = 0
try {
db.withTransaction {
// Import categories if present
val categoryIdMap = if (export.categories.isNotEmpty()) {
importCategories(export.categories, strategy).also {
categoriesImported = it.size
}
} else {
emptyMap()
}
// Import the single item
val states = if (export.state != null) listOf(export.state) else emptyList()
val stageMappings = listOf(StageMappingData(export.item.id, export.stage))
val itemIdMap = importItems(listOf(export.item), states, stageMappings, strategy)
itemsImported = itemIdMap.count { it.value >= 0 }
itemsSkipped = itemIdMap.count { it.value == -1 }
// Create category mappings
val newItemId = itemIdMap[export.item.id] ?: export.item.id
if (newItemId >= 0) {
val mappings = export.categories.map { category ->
val newCategoryId = categoryIdMap[category.id] ?: category.id
CategoryMappingData(newItemId, newCategoryId)
}
importCategoryMappings(mappings, mapOf(), mapOf())
}
}
requestUpdateMappings()
} catch (e: Exception) {
Log.e(TAG, "importSingleItem: Error during import", e)
errors.add("Import failed: ${e.message}")
}
return ImportResult(itemsImported, itemsSkipped, itemsUpdated, categoriesImported, errors)
}
/**
* Helper function to import categories with conflict resolution.
* Returns a map of old category IDs to new category IDs.
*/
private suspend fun importCategories(
categories: List<VocabularyCategory>,
strategy: ConflictStrategy
): Map<Int, Int> {
val idMap = mutableMapOf<Int, Int>()
val existingCategories = getAllCategories()
for (category in categories) {
val existing = existingCategories.find { it.name == category.name && it::class == category::class }
when {
existing != null && strategy == ConflictStrategy.SKIP -> {
// Skip, but map old ID to existing ID
idMap[category.id] = existing.id
Log.d(TAG, "importCategories: Skipping existing category '${category.name}'")
}
existing != null && strategy == ConflictStrategy.REPLACE -> {
// Replace existing category
val updated = when (category) {
is TagCategory -> category.copy(id = existing.id)
is VocabularyFilter -> category.copy(id = existing.id)
}
saveCategory(updated)
idMap[category.id] = existing.id
Log.d(TAG, "importCategories: Replaced category '${category.name}'")
}
existing != null && strategy == ConflictStrategy.MERGE -> {
// Keep existing, map old ID to existing ID
idMap[category.id] = existing.id
Log.d(TAG, "importCategories: Merged with existing category '${category.name}'")
}
strategy == ConflictStrategy.RENAME || existing == null -> {
// Assign new ID
val maxId = categoryDao.getAllCategories().maxOfOrNull { it.id } ?: 0
val newId = maxId + 1
val newCategory = when (category) {
is TagCategory -> category.copy(id = newId)
is VocabularyFilter -> category.copy(id = newId)
}
saveCategory(newCategory)
idMap[category.id] = newId
Log.d(TAG, "importCategories: Created new category '${category.name}' with id=$newId")
}
}
}
return idMap
}
/**
* Helper function to import vocabulary items with their states and stage mappings.
* Returns a map of old item IDs to new item IDs (-1 means skipped).
*/
private suspend fun importItems(
items: List<VocabularyItem>,
states: List<VocabularyItemState>,
stageMappings: List<StageMappingData>,
strategy: ConflictStrategy
): Map<Int, Int> {
val idMap = mutableMapOf<Int, Int>()
val existingItems = getAllVocabularyItems()
val stateMap = states.associateBy { it.vocabularyItemId }
val stageMap = stageMappings.associate { it.vocabularyItemId to it.stage }
for (item in items) {
val duplicate = existingItems.find { it.isDuplicate(item) }
when {
duplicate != null && strategy == ConflictStrategy.SKIP -> {
// Skip this item
idMap[item.id] = -1
Log.d(TAG, "importItems: Skipping duplicate item '${item.wordFirst}'")
}
duplicate != null && strategy == ConflictStrategy.REPLACE -> {
// Replace with imported version
val updated = item.copy(id = duplicate.id)
itemDao.upsertItem(updated)
idMap[item.id] = duplicate.id
// Update state and stage
stateMap[item.id]?.let { state ->
stateDao.upsertState(state.copy(vocabularyItemId = duplicate.id))
}
stageMap[item.id]?.let { stage ->
mappingDao.upsertStageMapping(StageMappingEntity(duplicate.id, stage))
}
Log.d(TAG, "importItems: Replaced item '${item.wordFirst}'")
}
duplicate != null && strategy == ConflictStrategy.MERGE -> {
// Merge: keep item, merge states (keep better progress)
idMap[item.id] = duplicate.id
stateMap[item.id]?.let { importedState ->
val existingState = getVocabularyItemStateById(duplicate.id)
val mergedState = mergeStates(existingState, importedState, duplicate.id)
stateDao.upsertState(mergedState)
}
stageMap[item.id]?.let { importedStage ->
val existingStage = loadStageMapping().first()[duplicate.id] ?: VocabularyStage.NEW
val mergedStage = maxOf(importedStage, existingStage)
mappingDao.upsertStageMapping(StageMappingEntity(duplicate.id, mergedStage))
}
Log.d(TAG, "importItems: Merged item '${item.wordFirst}'")
}
strategy == ConflictStrategy.RENAME || duplicate == null -> {
// Assign new ID
val maxId = itemDao.getMaxItemId() ?: 0
val newId = maxId + idMap.size + 1
val newItem = item.copy(id = newId)
itemDao.upsertItem(newItem)
idMap[item.id] = newId
// Import state and stage with new ID
stateMap[item.id]?.let { state ->
stateDao.upsertState(state.copy(vocabularyItemId = newId))
}
stageMap[item.id]?.let { stage ->
mappingDao.upsertStageMapping(StageMappingEntity(newId, stage))
}
Log.d(TAG, "importItems: Created new item '${item.wordFirst}' with id=$newId")
}
}
}
return idMap
}
/**
* Helper function to import category mappings with remapped IDs.
*/
private suspend fun importCategoryMappings(
mappings: List<CategoryMappingData>,
itemIdMap: Map<Int, Int>,
categoryIdMap: Map<Int, Int>
) {
for (mapping in mappings) {
val newItemId = itemIdMap[mapping.vocabularyItemId] ?: mapping.vocabularyItemId
val newCategoryId = categoryIdMap[mapping.categoryId] ?: mapping.categoryId
// Skip if item was skipped during import
if (newItemId < 0) continue
mappingDao.addCategoryMapping(CategoryMappingEntity(newItemId, newCategoryId))
}
}
/**
* Helper function to merge two vocabulary item states.
* Keeps the more advanced learning progress.
*/
private fun mergeStates(
existing: VocabularyItemState?,
imported: VocabularyItemState,
itemId: Int
): VocabularyItemState {
if (existing == null) return imported.copy(vocabularyItemId = itemId)
return VocabularyItemState(
vocabularyItemId = itemId,
lastCorrectAnswer = maxOfNullable(existing.lastCorrectAnswer, imported.lastCorrectAnswer),
lastIncorrectAnswer = maxOfNullable(existing.lastIncorrectAnswer, imported.lastIncorrectAnswer),
correctAnswerCount = max(existing.correctAnswerCount, imported.correctAnswerCount),
incorrectAnswerCount = max(existing.incorrectAnswerCount, imported.incorrectAnswerCount)
)
}
/**
* Helper function to get the maximum of two nullable Instants.
*/
private fun maxOfNullable(a: Instant?, b: Instant?): Instant? {
return when {
a == null -> b
b == null -> a
else -> if (a > b) a else b
}
}
}
@Serializable

View File

@@ -5,22 +5,22 @@ import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.ui.graphics.Color
import eu.gaudian.translator.ui.theme.themes.AutumnSpiceTheme
import eu.gaudian.translator.ui.theme.themes.CitrusSplashTheme
import eu.gaudian.translator.ui.theme.themes.CoffeeTheme
import eu.gaudian.translator.ui.theme.themes.CrimsonTheme
import eu.gaudian.translator.ui.theme.themes.CyberpunkTheme
import eu.gaudian.translator.ui.theme.themes.DebugTheme
import eu.gaudian.translator.ui.theme.themes.DefaultTheme
import eu.gaudian.translator.ui.theme.themes.ElectricVioletTheme
import eu.gaudian.translator.ui.theme.themes.ForestTheme
import eu.gaudian.translator.ui.theme.themes.LavenderDreamTheme
import eu.gaudian.translator.ui.theme.themes.MossStoneTheme
import eu.gaudian.translator.ui.theme.themes.NeonPulseTheme
import eu.gaudian.translator.ui.theme.themes.NordTheme
import eu.gaudian.translator.ui.theme.themes.OceanicCalmTheme
import eu.gaudian.translator.ui.theme.themes.PixelTheme
import eu.gaudian.translator.ui.theme.themes.SakuraTheme
import eu.gaudian.translator.ui.theme.themes.SageGardenTheme
import eu.gaudian.translator.ui.theme.themes.SlateAndStoneTheme
import eu.gaudian.translator.ui.theme.themes.SpaceTheme
import eu.gaudian.translator.ui.theme.themes.SynthwaveTheme
import eu.gaudian.translator.ui.theme.themes.TealTheme
import eu.gaudian.translator.ui.theme.themes.TwilightSerenityTheme
import eu.gaudian.translator.ui.theme.themes.TerracottaEarthTheme
/**
* A data class to hold the core colors for a theme variation (light or dark).
@@ -97,26 +97,23 @@ data class AppTheme(
val AllThemes = listOf(
DefaultTheme,
PixelTheme,
CrimsonTheme,
SakuraTheme,
AutumnSpiceTheme,
TealTheme,
ForestTheme,
CoffeeTheme,
CitrusSplashTheme,
OceanicCalmTheme,
SlateAndStoneTheme,
NordTheme,
TwilightSerenityTheme,
SpaceTheme,
CyberpunkTheme,
SynthwaveTheme,
DebugTheme,
LavenderDreamTheme,
SageGardenTheme,
MossStoneTheme,
ElectricVioletTheme,
NeonPulseTheme,
TerracottaEarthTheme,
)
/**

View File

@@ -1,85 +0,0 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.ui.theme.themes
import androidx.compose.ui.graphics.Color
import eu.gaudian.translator.ui.theme.AppTheme
import eu.gaudian.translator.ui.theme.ThemeColorSet
val CitrusSplashTheme = AppTheme(
name = "Citrus Splash",
lightColors = ThemeColorSet(
primary = Color(0xFFF57F17), // Vibrant Orange (Primary)
secondary = Color(0xFFFBC02D), // Sunny Yellow (Secondary)
tertiary = Color(0xFF7CB342), // Lime Green (Tertiary)
primaryContainer = Color(0xFFFFEBC0),
secondaryContainer = Color(0xFFFFF3AD),
tertiaryContainer = Color(0xFFDDEEBF),
onPrimary = Color(0xFFFFFFFF),
onSecondary = Color(0xFF000000),
onTertiary = Color(0xFFFFFFFF),
onPrimaryContainer = Color(0xFF2C1600),
onSecondaryContainer = Color(0xFF221B00),
onTertiaryContainer = Color(0xFF131F00),
error = Color(0xFFB00020),
onError = Color(0xFFFFFFFF),
errorContainer = Color(0xFFFDE7E9),
onErrorContainer = Color(0xFF4A000B),
background = Color(0xFFFFFDF7), // Warm, off-white background
onBackground = Color(0xFF201A17), // Dark, warm text
surface = Color(0xFFFFFFFF), // Crisp white surface
onSurface = Color(0xFF201A17),
surfaceVariant = Color(0xFFF3EFE9),
onSurfaceVariant = Color(0xFF49453F),
outline = Color(0xFF7A756F),
outlineVariant = Color(0xFFCCC5BD),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFF352F2B),
inverseOnSurface = Color(0xFFFBEFE8),
inversePrimary = Color(0xFFFFB86C),
surfaceDim = Color(0xFFE2D8D2),
surfaceBright = Color(0xFFFFFDF7),
surfaceContainerLowest = Color(0xFFFFFFFF),
surfaceContainerLow = Color(0xFFFBF2EC),
surfaceContainer = Color(0xFFF5EDE6),
surfaceContainerHigh = Color(0xFFF0E7E1),
surfaceContainerHighest = Color(0xFFEAE2DC)
),
darkColors = ThemeColorSet(
primary = Color(0xFFFFB86C), // Lighter orange for dark mode
secondary = Color(0xFFEAC248), // Lighter yellow
tertiary = Color(0xFFB8CF83), // Lighter lime
primaryContainer = Color(0xFF5A4121),
secondaryContainer = Color(0xFF564600),
tertiaryContainer = Color(0xFF404D20),
onPrimary = Color(0xFF4A2A00),
onSecondary = Color(0xFF3A3000),
onTertiary = Color(0xFF2B350A),
onPrimaryContainer = Color(0xFFFFDEB5),
onSecondaryContainer = Color(0xFFFFEAAA),
onTertiaryContainer = Color(0xFFD4EC9C),
error = Color(0xFFCF6679),
onError = Color(0xFF000000),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF1F1A17), // Deep, warm brown/gray
onBackground = Color(0xFFEAE2DC), // Light, warm text
surface = Color(0xFF2A2421), // Slightly lighter warm surface
onSurface = Color(0xFFEAE2DC),
surfaceVariant = Color(0xFF443F3A),
onSurfaceVariant = Color(0xFFC9C6C0),
outline = Color(0xFF938F8A),
outlineVariant = Color(0xFF49453F),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFFEAE2DC),
inverseOnSurface = Color(0xFF201A17),
inversePrimary = Color(0xFFF57F17),
surfaceDim = Color(0xFF1F1A17),
surfaceBright = Color(0xFF48403A),
surfaceContainerLowest = Color(0xFF16120F),
surfaceContainerLow = Color(0xFF1F1A17),
surfaceContainer = Color(0xFF241E1B),
surfaceContainerHigh = Color(0xFF2E2925),
surfaceContainerHighest = Color(0xFF39332F),
)
)

View File

@@ -1,85 +0,0 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.ui.theme.themes
import androidx.compose.ui.graphics.Color
import eu.gaudian.translator.ui.theme.AppTheme
import eu.gaudian.translator.ui.theme.ThemeColorSet
val CrimsonTheme = AppTheme(
name = "Crimson",
lightColors = ThemeColorSet(
primary = Color(0xFFA03F3F),
secondary = Color(0xFF775656),
tertiary = Color(0xFF755A2F),
primaryContainer = Color(0xFFFFDAD9),
secondaryContainer = Color(0xFFFFDAD9),
tertiaryContainer = Color(0xFFFFDEAD),
onPrimary = Color(0xFFFFFFFF),
onSecondary = Color(0xFFFFFFFF),
onTertiary = Color(0xFFFFFFFF),
onPrimaryContainer = Color(0xFF410004),
onSecondaryContainer = Color(0xFF2C1515),
onTertiaryContainer = Color(0xFF281900),
error = Color(0xFFBA1A1A),
onError = Color(0xFFFFFFFF),
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002),
background = Color(0xFFFCFCFC),
onBackground = Color(0xFF201A1A),
surface = Color(0xFFFCFCFC),
onSurface = Color(0xFF201A1A),
surfaceVariant = Color(0xFFF4DDDD),
onSurfaceVariant = Color(0xFF524343),
outline = Color(0xFF857373),
outlineVariant = Color(0xFFD7C1C1),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFF362F2F),
inverseOnSurface = Color(0xFFFBEDED),
inversePrimary = Color(0xFFFFB3B3),
surfaceDim = Color(0xFFE3D7D7),
surfaceBright = Color(0xFFFCFCFC),
surfaceContainerLowest = Color(0xFFFFFFFF),
surfaceContainerLow = Color(0xFFF7F0F0),
surfaceContainer = Color(0xFFF1EAEB),
surfaceContainerHigh = Color(0xFFEBE4E5),
surfaceContainerHighest = Color(0xFFE5DFDF)
),
darkColors = ThemeColorSet(
primary = Color(0xFFFFB3B3),
secondary = Color(0xFFE6BDBC),
tertiary = Color(0xFFE5C18D),
primaryContainer = Color(0xFF812829),
secondaryContainer = Color(0xFF5D3F3F),
tertiaryContainer = Color(0xFF5B431A),
onPrimary = Color(0xFF611216),
onSecondary = Color(0xFF442929),
onTertiary = Color(0xFF412D05),
onPrimaryContainer = Color(0xFFFFDAD9),
onSecondaryContainer = Color(0xFFFFDAD9),
onTertiaryContainer = Color(0xFFFFDEAD),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF201A1A),
onBackground = Color(0xFFEBE0E0),
surface = Color(0xFF201A1A),
onSurface = Color(0xFFEBE0E0),
surfaceVariant = Color(0xFF524343),
onSurfaceVariant = Color(0xFFD7C1C1),
outline = Color(0xFFA08C8C),
outlineVariant = Color(0xFF524343),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFFEBE0E0),
inverseOnSurface = Color(0xFF362F2F),
inversePrimary = Color(0xFFA03F3F),
surfaceDim = Color(0xFF171212),
surfaceBright = Color(0xFF3E3737),
surfaceContainerLowest = Color(0xFF120D0D),
surfaceContainerLow = Color(0xFF251E1E),
surfaceContainer = Color(0xFF2A2222),
surfaceContainerHigh = Color(0xFF342C2C),
surfaceContainerHighest = Color(0xFF3F3737),
)
)

View File

@@ -0,0 +1,85 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.ui.theme.themes
import androidx.compose.ui.graphics.Color
import eu.gaudian.translator.ui.theme.AppTheme
import eu.gaudian.translator.ui.theme.ThemeColorSet
val ElectricVioletTheme = AppTheme(
name = "Electric Violet",
lightColors = ThemeColorSet(
primary = Color(0xFF7B2CBF),
secondary = Color(0xFF9D4EDD),
tertiary = Color(0xFFC77DFF),
primaryContainer = Color(0xFFE8D4FF),
secondaryContainer = Color(0xFFF0D4FF),
tertiaryContainer = Color(0xFFFFD4FF),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color(0xFF3D0066),
onPrimaryContainer = Color(0xFF2E0060),
onSecondaryContainer = Color(0xFF3D0066),
onTertiaryContainer = Color(0xFF4D007A),
error = Color(0xFFBA1A1A),
onError = Color.White,
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002),
background = Color(0xFFFEFDFF),
onBackground = Color(0xFF1C1B20),
surface = Color(0xFFFEFDFF),
onSurface = Color(0xFF1C1B20),
surfaceVariant = Color(0xFFE7DEF0),
onSurfaceVariant = Color(0xFF4F444B),
outline = Color(0xFF80737A),
outlineVariant = Color(0xFFD3C2CA),
scrim = Color.Black,
inverseSurface = Color(0xFF343035),
inverseOnSurface = Color(0xFFF9EFF6),
inversePrimary = Color(0xFFE0B0FF),
surfaceDim = Color(0xFFE0D8E0),
surfaceBright = Color(0xFFFEFDFF),
surfaceContainerLowest = Color.White,
surfaceContainerLow = Color(0xFFF9F2FA),
surfaceContainer = Color(0xFFF3ECF4),
surfaceContainerHigh = Color(0xFFEDE7EE),
surfaceContainerHighest = Color(0xFFE7E1E9)
),
darkColors = ThemeColorSet(
primary = Color(0xFFE0B0FF),
secondary = Color(0xFFC590E0),
tertiary = Color(0xFFE0A0FF),
primaryContainer = Color(0xFF5A2D8A),
secondaryContainer = Color(0xFF6A3A7A),
tertiaryContainer = Color(0xFF5A3A7A),
onPrimary = Color(0xFF3D1A6A),
onSecondary = Color(0xFF3D2A5A),
onTertiary = Color(0xFF3D2A6A),
onPrimaryContainer = Color(0xFFE8D4FF),
onSecondaryContainer = Color(0xFFF0D4FF),
onTertiaryContainer = Color(0xFFFFD4FF),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF1C1B20),
onBackground = Color(0xFFE6E1E9),
surface = Color(0xFF1C1B20),
onSurface = Color(0xFFE6E1E9),
surfaceVariant = Color(0xFF4F444B),
onSurfaceVariant = Color(0xFFD3C2CA),
outline = Color(0xFF9C8D96),
outlineVariant = Color(0xFF4F444B),
scrim = Color.Black,
inverseSurface = Color(0xFFE6E1E9),
inverseOnSurface = Color(0xFF343035),
inversePrimary = Color(0xFF7B2CBF),
surfaceDim = Color(0xFF141217),
surfaceBright = Color(0xFF3B373E),
surfaceContainerLowest = Color(0xFF0E0D12),
surfaceContainerLow = Color(0xFF1C1B20),
surfaceContainer = Color(0xFF201F24),
surfaceContainerHigh = Color(0xFF2B292F),
surfaceContainerHighest = Color(0xFF36343A)
)
)

View File

@@ -0,0 +1,85 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.ui.theme.themes
import androidx.compose.ui.graphics.Color
import eu.gaudian.translator.ui.theme.AppTheme
import eu.gaudian.translator.ui.theme.ThemeColorSet
val LavenderDreamTheme = AppTheme(
name = "Lavender Dream",
lightColors = ThemeColorSet(
primary = Color(0xFF6B5B95), // Deep Lavender
secondary = Color(0xFF8874A3), // Soft Purple
tertiary = Color(0xFFBFA6C8), // Pale Lavender
primaryContainer = Color(0xFFE8DEFF),
secondaryContainer = Color(0xFFF3E8FF),
tertiaryContainer = Color(0xFFFFE8FF),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color(0xFF3D2E4D),
onPrimaryContainer = Color(0xFF251A4A),
onSecondaryContainer = Color(0xFF2D1F4A),
onTertiaryContainer = Color(0xFF3D2E4D),
error = Color(0xFFBA1A1A),
onError = Color.White,
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002),
background = Color(0xFFFDFBFF),
onBackground = Color(0xFF1C1B20),
surface = Color(0xFFFDFBFF),
onSurface = Color(0xFF1C1B20),
surfaceVariant = Color(0xFFE7E0EB),
onSurfaceVariant = Color(0xFF49454E),
outline = Color(0xFF7A757F),
outlineVariant = Color(0xFFCBC4CF),
scrim = Color.Black,
inverseSurface = Color(0xFF313035),
inverseOnSurface = Color(0xFFF3EFF6),
inversePrimary = Color(0xFFCBB8FF),
surfaceDim = Color(0xFFDED9E0),
surfaceBright = Color(0xFFFDFBFF),
surfaceContainerLowest = Color.White,
surfaceContainerLow = Color(0xFFF8F2FA),
surfaceContainer = Color(0xFFF2ECF4),
surfaceContainerHigh = Color(0xFFECE7EF),
surfaceContainerHighest = Color(0xFFE6E1E9)
),
darkColors = ThemeColorSet(
primary = Color(0xFFCBB8FF), // Soft Lavender
secondary = Color(0xFFD4C4E8), // Light Purple
tertiary = Color(0xFFE0D0F0), // Very Pale Purple
primaryContainer = Color(0xFF52437A),
secondaryContainer = Color(0xFF5D4A73),
tertiaryContainer = Color(0xFF4A3D5C),
onPrimary = Color(0xFF3B2E6A),
onSecondary = Color(0xFF3D2A54),
onTertiary = Color(0xFF3D2A54),
onPrimaryContainer = Color(0xFFE8DEFF),
onSecondaryContainer = Color(0xFFF3E8FF),
onTertiaryContainer = Color(0xFFFFE8FF),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF1C1B20),
onBackground = Color(0xFFE6E1E9),
surface = Color(0xFF1C1B20),
onSurface = Color(0xFFE6E1E9),
surfaceVariant = Color(0xFF49454E),
onSurfaceVariant = Color(0xFFCBC4CF),
outline = Color(0xFF948F99),
outlineVariant = Color(0xFF49454E),
scrim = Color.Black,
inverseSurface = Color(0xFFE6E1E9),
inverseOnSurface = Color(0xFF313035),
inversePrimary = Color(0xFF6B5B95),
surfaceDim = Color(0xFF141317),
surfaceBright = Color(0xFF3A383E),
surfaceContainerLowest = Color(0xFF0F0E12),
surfaceContainerLow = Color(0xFF1C1B20),
surfaceContainer = Color(0xFF201F24),
surfaceContainerHigh = Color(0xFF2B292F),
surfaceContainerHighest = Color(0xFF36343A)
)
)

View File

@@ -0,0 +1,85 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.ui.theme.themes
import androidx.compose.ui.graphics.Color
import eu.gaudian.translator.ui.theme.AppTheme
import eu.gaudian.translator.ui.theme.ThemeColorSet
val MossStoneTheme = AppTheme(
name = "Moss & Stone",
lightColors = ThemeColorSet(
primary = Color(0xFF4A6356), // Deep Moss
secondary = Color(0xFF6B6B6B), // Stone Gray
tertiary = Color(0xFF8B9A7C), // Sage Olive
primaryContainer = Color(0xFFC8D8CE),
secondaryContainer = Color(0xFFE0E0E0),
tertiaryContainer = Color(0xFFE8EFE0),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color(0xFF1D2D1A),
onPrimaryContainer = Color(0xFF0D1F15),
onSecondaryContainer = Color(0xFF1F1F1F),
onTertiaryContainer = Color(0xFF2D3A20),
error = Color(0xFFBA1A1A),
onError = Color.White,
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002),
background = Color(0xFFF8F9F6),
onBackground = Color(0xFF1A1C1A),
surface = Color(0xFFF8F9F6),
onSurface = Color(0xFF1A1C1A),
surfaceVariant = Color(0xFFD4D9D2),
onSurfaceVariant = Color(0xFF41483D),
outline = Color(0xFF71786D),
outlineVariant = Color(0xFFC1C8C1),
scrim = Color.Black,
inverseSurface = Color(0xFF2F312D),
inverseOnSurface = Color(0xFFF0F1ED),
inversePrimary = Color(0xFFB1CCB8),
surfaceDim = Color(0xFFD8DAD4),
surfaceBright = Color(0xFFF8F9F6),
surfaceContainerLowest = Color.White,
surfaceContainerLow = Color(0xFFF2F4F0),
surfaceContainer = Color(0xFFECEFEA),
surfaceContainerHigh = Color(0xFFE6E9E4),
surfaceContainerHighest = Color(0xFFE0E3DE)
),
darkColors = ThemeColorSet(
primary = Color(0xFFB1CCB8), // Soft Moss
secondary = Color(0xFFB8B8B8), // Light Stone
tertiary = Color(0xFFD4E0C0), // Light Olive
primaryContainer = Color(0xFF354B3F),
secondaryContainer = Color(0xFF404040),
tertiaryContainer = Color(0xFF4A5235),
onPrimary = Color(0xFF0D1F15),
onSecondary = Color(0xFF1F1F1F),
onTertiary = Color(0xFF2D3A20),
onPrimaryContainer = Color(0xFFC8D8CE),
onSecondaryContainer = Color(0xFFE0E0E0),
onTertiaryContainer = Color(0xFFE8EFE0),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF1A1C1A),
onBackground = Color(0xFFE0E3DE),
surface = Color(0xFF1A1C1A),
onSurface = Color(0xFFE0E3DE),
surfaceVariant = Color(0xFF41483D),
onSurfaceVariant = Color(0xFFC1C8C1),
outline = Color(0xFF8B9187),
outlineVariant = Color(0xFF41483D),
scrim = Color.Black,
inverseSurface = Color(0xFFE0E3DE),
inverseOnSurface = Color(0xFF2F312D),
inversePrimary = Color(0xFF4A6356),
surfaceDim = Color(0xFF121411),
surfaceBright = Color(0xFF383A36),
surfaceContainerLowest = Color(0xFF0D0F0E),
surfaceContainerLow = Color(0xFF1A1C1A),
surfaceContainer = Color(0xFF1E201D),
surfaceContainerHigh = Color(0xFF282B27),
surfaceContainerHighest = Color(0xFF333631)
)
)

View File

@@ -0,0 +1,85 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.ui.theme.themes
import androidx.compose.ui.graphics.Color
import eu.gaudian.translator.ui.theme.AppTheme
import eu.gaudian.translator.ui.theme.ThemeColorSet
val NeonPulseTheme = AppTheme(
name = "Neon Pulse",
lightColors = ThemeColorSet(
primary = Color(0xFFE91E63), // Hot Pink
secondary = Color(0xFF00BCD4), // Cyan
tertiary = Color(0xFFFFEB3B), // Bright Yellow
primaryContainer = Color(0xFFFFD6E0),
secondaryContainer = Color(0xFFB2EBF2),
tertiaryContainer = Color(0xFFFFF9C4),
onPrimary = Color.White,
onSecondary = Color(0xFF003640),
onTertiary = Color(0xFF3F3D00),
onPrimaryContainer = Color(0xFF3E001A),
onSecondaryContainer = Color(0xFF001F26),
onTertiaryContainer = Color(0xFF3D3D00),
error = Color(0xFFBA1A1A),
onError = Color.White,
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002),
background = Color(0xFFFFFBFF),
onBackground = Color(0xFF1C1B20),
surface = Color(0xFFFFFBFF),
onSurface = Color(0xFF1C1B20),
surfaceVariant = Color(0xFFF3DDE6),
onSurfaceVariant = Color(0xFF50434B),
outline = Color(0xFF84737A),
outlineVariant = Color(0xFFD8C2C9),
scrim = Color.Black,
inverseSurface = Color(0xFF343035),
inverseOnSurface = Color(0xFFF9EFF3),
inversePrimary = Color(0xFFFFB1C8),
surfaceDim = Color(0xFFE2D6DB),
surfaceBright = Color(0xFFFFFBFF),
surfaceContainerLowest = Color.White,
surfaceContainerLow = Color(0xFFFCF0F4),
surfaceContainer = Color(0xFFF6E9EE),
surfaceContainerHigh = Color(0xFFF1E3E8),
surfaceContainerHighest = Color(0xFFEBDEE3)
),
darkColors = ThemeColorSet(
primary = Color(0xFFFFB1C8), // Soft Pink
secondary = Color(0xFF7FDBE6), // Soft Cyan
tertiary = Color(0xFFFFF176), // Soft Yellow
primaryContainer = Color(0xFFC2185B),
secondaryContainer = Color(0xFF00838F),
tertiaryContainer = Color(0xFF5A5A00),
onPrimary = Color(0xFF5E002A),
onSecondary = Color(0xFF00363D),
onTertiary = Color(0xFF3D3D00),
onPrimaryContainer = Color(0xFFFFD6E0),
onSecondaryContainer = Color(0xFFB2EBF2),
onTertiaryContainer = Color(0xFFFFF9C4),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF1C1B20),
onBackground = Color(0xFFE6E1E6),
surface = Color(0xFF1C1B20),
onSurface = Color(0xFFE6E1E6),
surfaceVariant = Color(0xFF50434B),
onSurfaceVariant = Color(0xFFD8C2C9),
outline = Color(0xFFA08C95),
outlineVariant = Color(0xFF50434B),
scrim = Color.Black,
inverseSurface = Color(0xFFE6E1E6),
inverseOnSurface = Color(0xFF343035),
inversePrimary = Color(0xFFE91E63),
surfaceDim = Color(0xFF141217),
surfaceBright = Color(0xFF3B373D),
surfaceContainerLowest = Color(0xFF0E0D12),
surfaceContainerLow = Color(0xFF1C1B20),
surfaceContainer = Color(0xFF201F24),
surfaceContainerHigh = Color(0xFF2B292F),
surfaceContainerHighest = Color(0xFF36343A)
)
)

View File

@@ -0,0 +1,85 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.ui.theme.themes
import androidx.compose.ui.graphics.Color
import eu.gaudian.translator.ui.theme.AppTheme
import eu.gaudian.translator.ui.theme.ThemeColorSet
val SageGardenTheme = AppTheme(
name = "Sage Garden",
lightColors = ThemeColorSet(
primary = Color(0xFF5C7A5C), // Sage Green
secondary = Color(0xFF8B7355), // Warm Brown
tertiary = Color(0xFF6B8E6B), // Moss Green
primaryContainer = Color(0xFFD4E8D4),
secondaryContainer = Color(0xFFE8DDD0),
tertiaryContainer = Color(0xFFE0F0E0),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onPrimaryContainer = Color(0xFF1A3D1A),
onSecondaryContainer = Color(0xFF2C1F0D),
onTertiaryContainer = Color(0xFF1F3D1F),
error = Color(0xFFBA1A1A),
onError = Color.White,
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002),
background = Color(0xFFFAFDF7),
onBackground = Color(0xFF1A1C19),
surface = Color(0xFFFAFDF7),
onSurface = Color(0xFF1A1C19),
surfaceVariant = Color(0xFFDCE4D7),
onSurfaceVariant = Color(0xFF41483F),
outline = Color(0xFF71786E),
outlineVariant = Color(0xFFC1C8BC),
scrim = Color.Black,
inverseSurface = Color(0xFF2F312D),
inverseOnSurface = Color(0xFFF0F1EB),
inversePrimary = Color(0xFFC4DBC4),
surfaceDim = Color(0xFFDADAD5),
surfaceBright = Color(0xFFFAFDF7),
surfaceContainerLowest = Color.White,
surfaceContainerLow = Color(0xFFF4F7F0),
surfaceContainer = Color(0xFFEEF1EA),
surfaceContainerHigh = Color(0xFFE8EBE4),
surfaceContainerHighest = Color(0xFFE2E5DE)
),
darkColors = ThemeColorSet(
primary = Color(0xFFC4DBC4), // Soft Sage
secondary = Color(0xFFD4C4B0), // Warm Beige
tertiary = Color(0xFFB8D4B8), // Light Moss
primaryContainer = Color(0xFF445F45),
secondaryContainer = Color(0xFF5C4A3A),
tertiaryContainer = Color(0xFF3D5C3D),
onPrimary = Color(0xFF1D3D1D),
onSecondary = Color(0xFF3D2A1A),
onTertiary = Color(0xFF1D3D1D),
onPrimaryContainer = Color(0xFFD4E8D4),
onSecondaryContainer = Color(0xFFE8DDD0),
onTertiaryContainer = Color(0xFFE0F0E0),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF1A1C19),
onBackground = Color(0xFFE2E5DE),
surface = Color(0xFF1A1C19),
onSurface = Color(0xFFE2E5DE),
surfaceVariant = Color(0xFF41483F),
onSurfaceVariant = Color(0xFFC1C8BC),
outline = Color(0xFF8B9187),
outlineVariant = Color(0xFF41483F),
scrim = Color.Black,
inverseSurface = Color(0xFFE2E5DE),
inverseOnSurface = Color(0xFF2F312D),
inversePrimary = Color(0xFF5C7A5C),
surfaceDim = Color(0xFF121411),
surfaceBright = Color(0xFF383A36),
surfaceContainerLowest = Color(0xFF0F110E),
surfaceContainerLow = Color(0xFF1A1C19),
surfaceContainer = Color(0xFF1E201D),
surfaceContainerHigh = Color(0xFF282B27),
surfaceContainerHighest = Color(0xFF333631)
)
)

View File

@@ -1,85 +0,0 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.ui.theme.themes
import androidx.compose.ui.graphics.Color
import eu.gaudian.translator.ui.theme.AppTheme
import eu.gaudian.translator.ui.theme.ThemeColorSet
val SakuraTheme = AppTheme(
name = "Blossom Pink",
lightColors = ThemeColorSet(
primary = Color(0xFFB94565),
secondary = Color(0xFF755960),
tertiary = Color(0xFF805537),
primaryContainer = Color(0xFFFFD9DF),
secondaryContainer = Color(0xFFFFD9E2),
tertiaryContainer = Color(0xFFFFDCC2),
onPrimary = Color(0xFFFFFFFF),
onSecondary = Color(0xFFFFFFFF),
onTertiary = Color(0xFFFFFFFF),
onPrimaryContainer = Color(0xFF40001F),
onSecondaryContainer = Color(0xFF2B171D),
onTertiaryContainer = Color(0xFF311300),
error = Color(0xFFBA1A1A),
onError = Color(0xFFFFFFFF),
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002),
background = Color(0xFFFFF8F7),
onBackground = Color(0xFF221A1C),
surface = Color(0xFFFFF8F7),
onSurface = Color(0xFF221A1C),
surfaceVariant = Color(0xFFF2DEE1),
onSurfaceVariant = Color(0xFF514346),
outline = Color(0xFF837376),
outlineVariant = Color(0xFFD5C2C5),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFF382E30),
inverseOnSurface = Color(0xFFFDEDEF),
inversePrimary = Color(0xFFE3B9C2),
surfaceDim = Color(0xFFE8D6D8),
surfaceBright = Color(0xFFFFF8F7),
surfaceContainerLowest = Color(0xFFFFFFFF),
surfaceContainerLow = Color(0xFFFFF0F1),
surfaceContainer = Color(0xFFFCEAEF),
surfaceContainerHigh = Color(0xFFF6E4E9),
surfaceContainerHighest = Color(0xFFF1DEE4)
),
darkColors = ThemeColorSet(
primary = Color(0xFFE3B9C2),
secondary = Color(0xFFE3BDC6),
tertiary = Color(0xFFF3BC95),
primaryContainer = Color(0xFF982C4D),
secondaryContainer = Color(0xFF5C4148),
tertiaryContainer = Color(0xFF653F22),
onPrimary = Color(0xFF581535),
onSecondary = Color(0xFF422C32),
onTertiary = Color(0xFF4A280D),
onPrimaryContainer = Color(0xFFFFD9DF),
onSecondaryContainer = Color(0xFFFFD9E2),
onTertiaryContainer = Color(0xFFFFDCC2),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF221A1C),
onBackground = Color(0xFFF1DEE4),
surface = Color(0xFF221A1C),
onSurface = Color(0xFFF1DEE4),
surfaceVariant = Color(0xFF514346),
onSurfaceVariant = Color(0xFFD5C2C5),
outline = Color(0xFF9D8C8F),
outlineVariant = Color(0xFF514346),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFFF1DEE4),
inverseOnSurface = Color(0xFF221A1C),
inversePrimary = Color(0xFFB94565),
surfaceDim = Color(0xFF191214),
surfaceBright = Color(0xFF41373A),
surfaceContainerLowest = Color(0xFF140D0F),
surfaceContainerLow = Color(0xFF221A1C),
surfaceContainer = Color(0xFF261E20),
surfaceContainerHigh = Color(0xFF31282A),
surfaceContainerHighest = Color(0xFF3C3335)
)
)

View File

@@ -1,85 +0,0 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.ui.theme.themes
import androidx.compose.ui.graphics.Color
import eu.gaudian.translator.ui.theme.AppTheme
import eu.gaudian.translator.ui.theme.ThemeColorSet
val SpaceTheme = AppTheme(
name = "Space Opera",
lightColors = ThemeColorSet(
primary = Color(0xFF3399FF), // Hologram Blue
secondary = Color(0xFFFFA500), // Engine Glow Orange
tertiary = Color(0xFFE0E0E0),
primaryContainer = Color(0xFFD7E8FF),
secondaryContainer = Color(0xFFFFECCF),
tertiaryContainer = Color(0xFFF0F0F0),
onPrimary = Color(0xFFFFFFFF),
onSecondary = Color(0xFF000000),
onTertiary = Color(0xFF000000),
onPrimaryContainer = Color(0xFF001D35),
onSecondaryContainer = Color(0xFF271A00),
onTertiaryContainer = Color(0xFF1F1F1F),
error = Color(0xFFBA1A1A),
onError = Color(0xFFFFFFFF),
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002),
background = Color(0xFFF8F9FA), // Cockpit White
onBackground = Color(0xFF181C20),
surface = Color(0xFFF8F9FA),
onSurface = Color(0xFF181C20),
surfaceVariant = Color(0xFFDEE3EB),
onSurfaceVariant = Color(0xFF42474E),
outline = Color(0xFF72787E),
outlineVariant = Color(0xFFC2C7CE),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFF2D3135),
inverseOnSurface = Color(0xFFF0F2F5),
inversePrimary = Color(0xFFADC6FF),
surfaceDim = Color(0xFFD9DADD),
surfaceBright = Color(0xFFF8F9FA),
surfaceContainerLowest = Color(0xFFFFFFFF),
surfaceContainerLow = Color(0xFFF2F3F5),
surfaceContainer = Color(0xFFECEEF0),
surfaceContainerHigh = Color(0xFFE6E8EA),
surfaceContainerHighest = Color(0xFFE1E3E5)
),
darkColors = ThemeColorSet(
primary = Color(0xFFADC6FF), // Nebula Blue
secondary = Color(0xFFFFB74D), // Thruster Orange
tertiary = Color(0xFFE0E0E0), // Starlight
primaryContainer = Color(0xFF004488),
secondaryContainer = Color(0xFF664200),
tertiaryContainer = Color(0xFF424242),
onPrimary = Color(0xFF002F54),
onSecondary = Color(0xFF3F2800),
onTertiary = Color(0xFF000000),
onPrimaryContainer = Color(0xFFD7E8FF),
onSecondaryContainer = Color(0xFFFFDDBF),
onTertiaryContainer = Color(0xFFFAFAFA),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF101418), // Deep Space
onBackground = Color(0xFFE2E2E6),
surface = Color(0xFF101418),
onSurface = Color(0xFFE2E2E6),
surfaceVariant = Color(0xFF42474E),
onSurfaceVariant = Color(0xFFC2C7CE),
outline = Color(0xFF8C9198),
outlineVariant = Color(0xFF42474E),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFFE2E2E6),
inverseOnSurface = Color(0xFF181C20),
inversePrimary = Color(0xFF3399FF),
surfaceDim = Color(0xFF101418),
surfaceBright = Color(0xFF363A3F),
surfaceContainerLowest = Color(0xFF0B0F13),
surfaceContainerLow = Color(0xFF181C20),
surfaceContainer = Color(0xFF1C2024),
surfaceContainerHigh = Color(0xFF272B2F),
surfaceContainerHighest = Color(0xFF32363A)
)
)

View File

@@ -1,85 +0,0 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.ui.theme.themes
import androidx.compose.ui.graphics.Color
import eu.gaudian.translator.ui.theme.AppTheme
import eu.gaudian.translator.ui.theme.ThemeColorSet
val SynthwaveTheme = AppTheme(
name = "Synthwave '84",
lightColors = ThemeColorSet(
primary = Color(0xFFC50083), // Darker Magenta for light theme contrast
secondary = Color(0xFF006874), // Darker Teal
tertiary = Color(0xFF7A5900),
primaryContainer = Color(0xFFFFD8EC),
secondaryContainer = Color(0xFFB3F0FF),
tertiaryContainer = Color(0xFFFFE26E),
onPrimary = Color(0xFFFFFFFF),
onSecondary = Color(0xFFFFFFFF),
onTertiary = Color(0xFFFFFFFF),
onPrimaryContainer = Color(0xFF40002A),
onSecondaryContainer = Color(0xFF001F24),
onTertiaryContainer = Color(0xFF261A00),
error = Color(0xFFBA1A1A),
onError = Color(0xFFFFFFFF),
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002),
background = Color(0xFFFDF7FF), // A very light lavender/off-white
onBackground = Color(0xFF1F1A21), // Dark Purple for text
surface = Color(0xFFFDF7FF),
onSurface = Color(0xFF1F1A21),
surfaceVariant = Color(0xFFE8E0F3),
onSurfaceVariant = Color(0xFF49454E),
outline = Color(0xFF7A757E),
outlineVariant = Color(0xFFCBC4CE),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFF342F36),
inverseOnSurface = Color(0xFFF5EFF7),
inversePrimary = Color(0xFFF475CB), // The vibrant pink from dark theme
surfaceDim = Color(0xFFE0D8E2),
surfaceBright = Color(0xFFFDF7FF),
surfaceContainerLowest = Color(0xFFFFFFFF),
surfaceContainerLow = Color(0xFFF7F1FA),
surfaceContainer = Color(0xFFF1EBF4),
surfaceContainerHigh = Color(0xFFECE5EE),
surfaceContainerHighest = Color(0xFFE6E0E9)
),
darkColors = ThemeColorSet(
primary = Color(0xFFF475CB), // Vibrant Magenta
secondary = Color(0xFF6AD9E8), // Electric Cyan
tertiary = Color(0xFFFFD400), // Sunset Yellow
primaryContainer = Color(0xFF660044),
secondaryContainer = Color(0xFF005A66),
tertiaryContainer = Color(0xFF665500),
onPrimary = Color(0xFF50003A),
onSecondary = Color(0xFF00363D),
onTertiary = Color(0xFF352D00),
onPrimaryContainer = Color(0xFFFFD8EC),
onSecondaryContainer = Color(0xFFB3F0FF),
onTertiaryContainer = Color(0xFFFFE26E),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF1A103C), // Deep Indigo
onBackground = Color(0xFFE0E5FF), // Pale Lavender Text
surface = Color(0xFF1A103C),
onSurface = Color(0xFFE0E5FF),
surfaceVariant = Color(0xFF49454E),
onSurfaceVariant = Color(0xFFCBC4CE),
outline = Color(0xFF948F99),
outlineVariant = Color(0xFF49454E),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFFE6E0E9),
inverseOnSurface = Color(0xFF342F36),
inversePrimary = Color(0xFFC50083),
surfaceDim = Color(0xFF151218),
surfaceBright = Color(0xFF3C383E),
surfaceContainerLowest = Color(0xFF100D13),
surfaceContainerLow = Color(0xFF1F1A21),
surfaceContainer = Color(0xFF231E25),
surfaceContainerHigh = Color(0xFF2E292F),
surfaceContainerHighest = Color(0xFF39333A)
)
)

View File

@@ -0,0 +1,85 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.ui.theme.themes
import androidx.compose.ui.graphics.Color
import eu.gaudian.translator.ui.theme.AppTheme
import eu.gaudian.translator.ui.theme.ThemeColorSet
val TerracottaEarthTheme = AppTheme(
name = "Terracotta Earth",
lightColors = ThemeColorSet(
primary = Color(0xFFB85C38), // Terracotta
secondary = Color(0xFF8B7355), // Warm Sand
tertiary = Color(0xFF6B8E6B), // Muted Olive
primaryContainer = Color(0xFFFFDCC8),
secondaryContainer = Color(0xFFEDE0D0),
tertiaryContainer = Color(0xFFE0F0E0),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onPrimaryContainer = Color(0xFF3D1700),
onSecondaryContainer = Color(0xFF2C1F0D),
onTertiaryContainer = Color(0xFF1F3D1F),
error = Color(0xFFBA1A1A),
onError = Color.White,
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002),
background = Color(0xFFFFFBF8),
onBackground = Color(0xFF231917),
surface = Color(0xFFFFFBF8),
onSurface = Color(0xFF231917),
surfaceVariant = Color(0xFFF5E0D8),
onSurfaceVariant = Color(0xFF53433F),
outline = Color(0xFF85736E),
outlineVariant = Color(0xFFD8C2BB),
scrim = Color.Black,
inverseSurface = Color(0xFF382E2B),
inverseOnSurface = Color(0xFFFFEDE8),
inversePrimary = Color(0xFFFFB599),
surfaceDim = Color(0xFFE8D6D1),
surfaceBright = Color(0xFFFFFBF8),
surfaceContainerLowest = Color.White,
surfaceContainerLow = Color(0xFFFFF1ED),
surfaceContainer = Color(0xFFFBEBE7),
surfaceContainerHigh = Color(0xFFF5E5E1),
surfaceContainerHighest = Color(0xFFF0E0DB)
),
darkColors = ThemeColorSet(
primary = Color(0xFFFFB599), // Soft Peach
secondary = Color(0xFFD4C4B0), // Warm Beige
tertiary = Color(0xFFB8D4B8), // Soft Olive
primaryContainer = Color(0xFF8B4020),
secondaryContainer = Color(0xFF5C4A3A),
tertiaryContainer = Color(0xFF3D5C3D),
onPrimary = Color(0xFF5D2A00),
onSecondary = Color(0xFF3D2A1A),
onTertiary = Color(0xFF1D3D1D),
onPrimaryContainer = Color(0xFFFFDCC8),
onSecondaryContainer = Color(0xFFEDE0D0),
onTertiaryContainer = Color(0xFFE0F0E0),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF1A110F),
onBackground = Color(0xFFF0E0DB),
surface = Color(0xFF1A110F),
onSurface = Color(0xFFF0E0DB),
surfaceVariant = Color(0xFF53433F),
onSurfaceVariant = Color(0xFFD8C2BB),
outline = Color(0xFFA08C87),
outlineVariant = Color(0xFF53433F),
scrim = Color.Black,
inverseSurface = Color(0xFFF0E0DB),
inverseOnSurface = Color(0xFF382E2B),
inversePrimary = Color(0xFFB85C38),
surfaceDim = Color(0xFF1A110F),
surfaceBright = Color(0xFF423734),
surfaceContainerLowest = Color(0xFF140C0A),
surfaceContainerLow = Color(0xFF231917),
surfaceContainer = Color(0xFF271D1B),
surfaceContainerHigh = Color(0xFF322825),
surfaceContainerHighest = Color(0xFF3D322F)
)
)

View File

@@ -1,85 +0,0 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.ui.theme.themes
import androidx.compose.ui.graphics.Color
import eu.gaudian.translator.ui.theme.AppTheme
import eu.gaudian.translator.ui.theme.ThemeColorSet
val TwilightSerenityTheme = AppTheme(
name = "Twilight Serenity",
lightColors = ThemeColorSet(
primary = Color(0xFF5A52A5),
secondary = Color(0xFF9A4555),
tertiary = Color(0xFF7A5900),
primaryContainer = Color(0xFFE2DFFF),
secondaryContainer = Color(0xFFFFD9DD),
tertiaryContainer = Color(0xFFFFDF9E),
onPrimary = Color(0xFFFFFFFF),
onSecondary = Color(0xFFFFFFFF),
onTertiary = Color(0xFFFFFFFF),
onPrimaryContainer = Color(0xFF16035F),
onSecondaryContainer = Color(0xFF400014),
onTertiaryContainer = Color(0xFF261A00),
error = Color(0xFFBA1A1A),
onError = Color(0xFFFFFFFF),
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002),
background = Color(0xFFFEFBFF),
onBackground = Color(0xFF1C1B20),
surface = Color(0xFFFEFBFF),
onSurface = Color(0xFF1C1B20),
surfaceVariant = Color(0xFFE5E0EC),
onSurfaceVariant = Color(0xFF47454E),
outline = Color(0xFF78757F),
outlineVariant = Color(0xFFC8C4CF),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFF313035),
inverseOnSurface = Color(0xFFF3EFF6),
inversePrimary = Color(0xFFC1C1FF),
surfaceDim = Color(0xFFDED9E0),
surfaceBright = Color(0xFFFEFBFF),
surfaceContainerLowest = Color(0xFFFFFFFF),
surfaceContainerLow = Color(0xFFF8F2FA),
surfaceContainer = Color(0xFFF2ECF4),
surfaceContainerHigh = Color(0xFFECE7EF),
surfaceContainerHighest = Color(0xFFE6E1E9)
),
darkColors = ThemeColorSet(
primary = Color(0xFFC1C1FF),
secondary = Color(0xFFFFB1BB),
tertiary = Color(0xFFF5BF48),
primaryContainer = Color(0xFF413A8C),
secondaryContainer = Color(0xFF7C2B3E),
tertiaryContainer = Color(0xFF5C4300),
onPrimary = Color(0xFF2C2275),
onSecondary = Color(0xFF5F1328),
onTertiary = Color(0xFF402D00),
onPrimaryContainer = Color(0xFFE2DFFF),
onSecondaryContainer = Color(0xFFFFD9DD),
onTertiaryContainer = Color(0xFFFFDF9E),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF1C1B20),
onBackground = Color(0xFFE6E1E9),
surface = Color(0xFF1C1B20),
onSurface = Color(0xFFE6E1E9),
surfaceVariant = Color(0xFF47454E),
onSurfaceVariant = Color(0xFFC8C4CF),
outline = Color(0xFF928F99),
outlineVariant = Color(0xFF47454E),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFFE6E1E9),
inverseOnSurface = Color(0xFF313035),
inversePrimary = Color(0xFF5A52A5),
surfaceDim = Color(0xFF141317),
surfaceBright = Color(0xFF3A383E),
surfaceContainerLowest = Color(0xFF0F0E12),
surfaceContainerLow = Color(0xFF1C1B20),
surfaceContainer = Color(0xFF201F24),
surfaceContainerHigh = Color(0xFF2B292F),
surfaceContainerHighest = Color(0xFF36343A)
)
)

View File

@@ -37,6 +37,7 @@ enum class StatusMessageId(
ERROR_VOCABULARY_IMPORT_FAILED(R.string.message_error_vocabulary_import_failed, MessageDisplayType.ERROR, 5),
SUCCESS_ITEMS_MERGED(R.string.message_success_items_merged, MessageDisplayType.SUCCESS, 3),
SUCCESS_ITEMS_ADDED(R.string.message_success_items_added, MessageDisplayType.SUCCESS, 3),
SUCCESS_All_ITEMS_IMPORTED(R.string.message_success_all_items_imported, MessageDisplayType.SUCCESS, 3),
ERROR_ITEMS_ADD_FAILED(R.string.message_error_items_add_failed, MessageDisplayType.ERROR, 5),
SUCCESS_ITEMS_DELETED(R.string.message_success_items_deleted, MessageDisplayType.SUCCESS, 3),
ERROR_ITEMS_DELETE_FAILED(R.string.message_error_items_delete_failed, MessageDisplayType.ERROR, 5),
@@ -66,6 +67,7 @@ enum class StatusMessageId(
// API Key related
ERROR_API_KEY_MISSING(R.string.message_error_api_key_missing, MessageDisplayType.ERROR, 0, MessageAction.NAVIGATE_TO_API_KEYS),
ERROR_API_KEY_INVALID(R.string.message_error_api_key_invalid, MessageDisplayType.ERROR, 0, MessageAction.NAVIGATE_TO_API_KEYS),
ERROR_NO_MODEL_CONFIGURED(R.string.message_error_no_model_configured, MessageDisplayType.ERROR, 5),
// Translation related
LOADING_TRANSLATING(R.string.message_loading_translating, MessageDisplayType.LOADING, 0),

View File

@@ -55,7 +55,9 @@ class TranslationService(private val context: Context) {
}
}
private suspend fun libreTranslate(text: String, source: String?, target: String, alternatives: Int = 1): Result<String> = withContext(Dispatchers.IO) {
// Public method to directly use LibreTranslate (bypasses AI)
suspend fun libreTranslate(text: String, source: String?, target: String, alternatives: Int = 1): Result<String> = withContext(Dispatchers.IO) {
Log.d("libreTranslate: $text, $source, $target")
try {
val json = org.json.JSONObject().apply {
put("q", text)

View File

@@ -82,19 +82,14 @@ val LocalConnectionConfigured = compositionLocalOf { true }
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
private val settingsViewModel: SettingsViewModel by viewModels()
private var isReady = false
private var isUiLoaded = false
private var isInitializing = true
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen().apply {
// The splash screen will now correctly wait until isReady is true
setKeepOnScreenCondition { !isReady }
}
super.onCreate(savedInstanceState)
lifecycleScope.launch {
@@ -104,28 +99,22 @@ class MainActivity : ComponentActivity() {
WindowCompat.setDecorFitsSystemWindows(window, false)
// Show UI immediately and load data in background
setContent {
AppTheme(settingsViewModel = settingsViewModel) {
TranslatorApp(settingsViewModel = settingsViewModel)
}
}
// Mark UI as loaded immediately after setContent
isUiLoaded = true
// Start initialization in background without blocking UI
initializeData()
}
private fun initializeData() {
lifecycleScope.launch(Dispatchers.IO) {
// Get repositories from the Application instance (lazy initialization)
val myApp = application as MyApplication
val languageRepository = myApp.languageRepository
val apiRepository = myApp.apiRepository
// Perform initialization in parallel where possible
val languageJob = launch {
languageRepository.initializeDefaultLanguages()
languageRepository.initializeAllLanguages()
@@ -135,13 +124,10 @@ class MainActivity : ComponentActivity() {
apiRepository.initialInit()
}
// Wait for both to complete
languageJob.join()
apiJob.join()
// Signal readiness after all work is done.
isReady = true
isInitializing = false
}
}
}
@@ -149,10 +135,7 @@ class MainActivity : ComponentActivity() {
@Suppress("AssignedValueIsNeverRead")
@SuppressLint("LocalContextResourcesRead")
@Composable
fun TranslatorApp(
settingsViewModel: SettingsViewModel
) {
fun TranslatorApp(settingsViewModel: SettingsViewModel) {
val activity = LocalContext.current.findActivity()
val statusViewModel: StatusViewModel = hiltViewModel(activity)
val statusMessageService = StatusMessageService
@@ -179,7 +162,6 @@ fun TranslatorApp(
showExitDialog = true
}
if (showExitDialog) {
AppAlertDialog(
onDismissRequest = { showExitDialog = false },
@@ -188,7 +170,6 @@ fun TranslatorApp(
confirmButton = {
TextButton(onClick = {
showExitDialog = false
// Minimize the app similar to default back at root behavior
activity.moveTaskToBack(true)
}) {
Text(stringResource(R.string.quit))
@@ -202,7 +183,6 @@ fun TranslatorApp(
)
}
// Check for app updates and show "What's New" dialog if needed
var showWhatsNewDialog by remember { mutableStateOf(false) }
val context = LocalContext.current
val changelogEntries = context.resources.getStringArray(R.array.changelog_entries)
@@ -210,7 +190,6 @@ fun TranslatorApp(
LaunchedEffect(Unit) {
try {
// Only check for updates if the intro is completed
if (introCompleted) {
val currentVersion = BuildConfig.VERSION_NAME
val hasSeenCurrentVersion = settingsViewModel.hasSeenCurrentVersion(currentVersion)
@@ -253,19 +232,25 @@ fun TranslatorApp(
val currentDestination = navBackStackEntry?.destination
val selectedScreen = Screen.fromDestination(currentDestination)
@Suppress("HardCodedStringLiteral") val isBottomBarHidden = currentDestination?.hierarchy?.any { destination ->
@Suppress("HardCodedStringLiteral")
val currentRoute = currentDestination?.route
val isHiddenByHierarchy = currentDestination?.hierarchy?.any { destination ->
destination.route in setOf(
Screen.Translation.route,
Screen.Dictionary.route,
Screen.Exercises.route,
Screen.Settings.route
Screen.Settings.route,
Screen.Corrector.route
)
} == true || currentDestination?.route in setOf(
"start_exercise",
} == true
val isBottomBarHidden = isHiddenByHierarchy || currentRoute in setOf(
"new_word",
"new_word_review",
"vocabulary_detail/{itemId}"
)
"vocabulary_detail/{itemId}",
"daily_review",
"explore_packs"
) || currentRoute?.startsWith("start_exercise") == true
|| currentRoute?.startsWith("vocabulary_exercise") == true
val showBottomNavLabels by settingsViewModel.showBottomNavLabels.collectAsStateWithLifecycle(initialValue = false)
BottomNavigationBar(
@@ -278,12 +263,11 @@ fun TranslatorApp(
Screen.Translation,
Screen.Dictionary,
Screen.Settings,
Screen.Exercises
Screen.Exercises,
Screen.Corrector
)
// Always reset the selected section to its root and clear back stack between sections
if (inSameSection) {
// If already within the same section, ensure we are at its graph root
navController.navigate(screen.route) {
popUpTo(screen.route) {
inclusive = false
@@ -298,9 +282,8 @@ fun TranslatorApp(
restoreState = false
}
} else {
// Switching sections: clear entire back stack to start to avoid back navigation results
navController.navigate(screen.route) {
popUpTo(0) { // Pop everything
popUpTo(0) {
inclusive = true
saveState = false
}
@@ -335,8 +318,7 @@ fun TranslatorApp(
statusState = statusState,
navController = navController,
onDismiss = { statusMessageService.trigger(StatusAction.HideMessageBar) },
modifier = Modifier
.fillMaxWidth()
modifier = Modifier.fillMaxWidth()
)
AppNavHost(
navController = navController,
@@ -388,10 +370,8 @@ private fun AppTheme(
val window = (view.context as Activity).window
val windowInsetsController = WindowInsetsControllerCompat(window, view)
// We must keep this for older Android version!!!
@Suppress("DEPRECATION")
window.statusBarColor = colorScheme.surface.toArgb()
//Elevation must be the same as BottomNavigationBar
@Suppress("DEPRECATION")
window.navigationBarColor = colorScheme.surfaceColorAtElevation(8.dp).toArgb()
@@ -438,6 +418,4 @@ private fun AppTheme(
content()
}
}
}
}

View File

@@ -20,27 +20,29 @@ import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import androidx.navigation.navigation
import eu.gaudian.translator.model.VocabularyStage
import eu.gaudian.translator.view.categories.CategoryDetailScreen
import eu.gaudian.translator.view.categories.CategoryListScreen
import eu.gaudian.translator.view.composable.Screen
import eu.gaudian.translator.view.dictionary.CorrectionScreen
import eu.gaudian.translator.view.dictionary.DictionaryResultScreen
import eu.gaudian.translator.view.dictionary.DictionaryScreen
import eu.gaudian.translator.view.dictionary.EtymologyResultScreen
import eu.gaudian.translator.view.dictionary.MainDictionaryScreen
import eu.gaudian.translator.view.exercises.ExerciseSessionScreen
import eu.gaudian.translator.view.exercises.MainExerciseScreen
import eu.gaudian.translator.view.exercises.StartExerciseScreen
import eu.gaudian.translator.view.exercises.YouTubeBrowserScreen
import eu.gaudian.translator.view.exercises.YouTubeExerciseScreen
import eu.gaudian.translator.view.home.DailyReviewScreen
import eu.gaudian.translator.view.home.HomeScreen
import eu.gaudian.translator.view.library.LibraryScreen
import eu.gaudian.translator.view.new_ecercises.ExerciseSessionScreen
import eu.gaudian.translator.view.new_ecercises.MainExerciseScreen
import eu.gaudian.translator.view.new_ecercises.StartExerciseScreen
import eu.gaudian.translator.view.new_ecercises.YouTubeBrowserScreen
import eu.gaudian.translator.view.new_ecercises.YouTubeExerciseScreen
import eu.gaudian.translator.view.settings.DictionaryOptionsScreen
import eu.gaudian.translator.view.settings.SettingsRoutes
import eu.gaudian.translator.view.settings.TranslationSettingsScreen
import eu.gaudian.translator.view.settings.settingsGraph
import eu.gaudian.translator.view.stats.StatsScreen
import eu.gaudian.translator.view.translation.TranslationScreen
import eu.gaudian.translator.view.vocabulary.AllCardsListScreen
import eu.gaudian.translator.view.vocabulary.CategoryDetailScreen
import eu.gaudian.translator.view.vocabulary.CategoryListScreen
import eu.gaudian.translator.view.vocabulary.LanguageProgressScreen
import eu.gaudian.translator.view.vocabulary.ExplorePacksScreen
import eu.gaudian.translator.view.vocabulary.LanguageJourneyScreen
import eu.gaudian.translator.view.vocabulary.NewWordReviewScreen
import eu.gaudian.translator.view.vocabulary.NewWordScreen
import eu.gaudian.translator.view.vocabulary.NoGrammarItemsScreen
@@ -53,10 +55,12 @@ import eu.gaudian.translator.view.vocabulary.VocabularySortingScreen
private const val TRANSITION_DURATION = 300
object NavigationRoutes {
const val DAILY_REVIEW = "daily_review"
const val NEW_WORD = "new_word"
const val NEW_WORD_REVIEW = "new_word_review"
const val VOCABULARY_DETAIL = "vocabulary_detail"
const val START_EXERCISE = "start_exercise"
const val START_EXERCISE_DAILY = "start_exercise_daily"
const val CATEGORY_DETAIL = "category_detail"
const val CATEGORY_LIST = "category_list_screen"
const val STATS_VOCABULARY_HEATMAP = "stats/vocabulary_heatmap"
@@ -66,75 +70,65 @@ object NavigationRoutes {
const val STATS_VOCABULARY_SORTING = "stats/vocabulary_sorting"
const val STATS_NO_GRAMMAR_ITEMS = "stats/no_grammar_items"
const val STATS_VOCABULARY_LIST = "stats/vocabulary_list"
const val EXPLORE_PACKS = "explore_packs"
}
@OptIn(androidx.compose.animation.ExperimentalAnimationApi::class)
@Composable
fun AppNavHost(
navController: NavHostController,
modifier: Modifier = Modifier
) {
// 1. Define your Main Tab "Leaf" Routes (the actual start destinations of your graphs)
val mainTabRoutes = setOf(
Screen.Home.route,
Screen.Library.route,
Screen.Stats.route,
Screen.Translation.route,
Screen.Dictionary.route,
Screen.Exercises.route,
SettingsRoutes.LIST
)
// Helper to check if a route is a top-level tab
fun isTabTransition(initial: String?, target: String?): Boolean {
return mainTabRoutes.contains(initial) && mainTabRoutes.contains(target)
if (initial == null || target == null) return false
val initialIsTab = mainTabRoutes.contains(initial) ||
mainTabRoutes.any { route ->
initial == "main_${route}" || initial.startsWith("${route}_")
}
val targetIsTab = mainTabRoutes.contains(target) ||
mainTabRoutes.any { route ->
target == "main_${route}" || target.startsWith("${route}_")
}
return initialIsTab && targetIsTab
}
NavHost(
navController = navController,
startDestination = Screen.Home.route,
modifier = modifier,
// ENTER TRANSITION
enterTransition = {
if (isTabTransition(initialState.destination.route, targetState.destination.route)) {
// Tab Switch: Just Fade In (Subtle Scale for modern feel)
fadeIn(animationSpec = tween(TRANSITION_DURATION)) +
scaleIn(initialScale = 0.96f, animationSpec = tween(TRANSITION_DURATION))
} else {
// Detail Screen: Slide in from Right
slideInHorizontally(
initialOffsetX = { fullWidth -> (fullWidth * 0.1f).toInt() },
animationSpec = tween(TRANSITION_DURATION, easing = FastOutSlowInEasing)
) + fadeIn(animationSpec = tween(TRANSITION_DURATION))
}
},
// EXIT TRANSITION
exitTransition = {
if (isTabTransition(initialState.destination.route, targetState.destination.route)) {
// Tab Switch: Just Fade Out
fadeOut(animationSpec = tween(TRANSITION_DURATION))
} else {
// Detail Screen: Slide out to Left
slideOutHorizontally(
targetOffsetX = { fullWidth -> -(fullWidth * 0.1f).toInt() },
animationSpec = tween(TRANSITION_DURATION, easing = FastOutSlowInEasing)
) + fadeOut(animationSpec = tween(TRANSITION_DURATION))
}
},
// POP ENTER (Pressing Back) -> Always Slide back from left
popEnterTransition = {
slideInHorizontally(
initialOffsetX = { fullWidth -> -(fullWidth * 0.1f).toInt() },
animationSpec = tween(TRANSITION_DURATION, easing = FastOutSlowInEasing)
) + fadeIn(animationSpec = tween(TRANSITION_DURATION))
},
// POP EXIT (Pressing Back) -> Always Slide away to right
popExitTransition = {
slideOutHorizontally(
targetOffsetX = { fullWidth -> (fullWidth * 0.1f).toInt() },
@@ -145,25 +139,49 @@ fun AppNavHost(
composable(Screen.Home.route) {
HomeScreen(navController = navController)
}
composable(NavigationRoutes.DAILY_REVIEW) {
DailyReviewScreen(navController = navController)
}
composable(NavigationRoutes.NEW_WORD) {
NewWordScreen(navController = navController)
}
composable(NavigationRoutes.NEW_WORD_REVIEW) {
NewWordReviewScreen(navController = navController)
}
composable(NavigationRoutes.START_EXERCISE) {
StartExerciseScreen(navController = navController)
composable(NavigationRoutes.EXPLORE_PACKS) {
ExplorePacksScreen(navController = navController)
}
composable(
route = "${NavigationRoutes.START_EXERCISE}?categoryId={categoryId}",
arguments = listOf(
navArgument("categoryId") {
type = NavType.StringType
nullable = true
defaultValue = null
}
)
) { backStackEntry ->
val categoryIdString = backStackEntry.arguments?.getString("categoryId")
val categoryId = categoryIdString?.toIntOrNull()
StartExerciseScreen(
navController = navController,
preselectedCategoryId = categoryId,
dueTodayOnly = false
)
}
composable(NavigationRoutes.START_EXERCISE_DAILY) {
StartExerciseScreen(
navController = navController,
preselectedCategoryId = null,
dueTodayOnly = true
)
}
// Define all other navigation graphs at the same top level.
homeGraph(navController)
libraryGraph(navController)
statsGraph(navController)
translationGraph(navController)
dictionaryGraph(navController)
correctorGraph(navController)
exerciseGraph(navController)
settingsGraph(navController)
}
@@ -189,9 +207,7 @@ fun NavGraphBuilder.libraryGraph(navController: NavHostController) {
LibraryScreen(navController = navController)
}
composable("vocabulary_sorting") {
VocabularySortingScreen(
navController = navController
)
VocabularySortingScreen(navController = navController)
}
composable("vocabulary_detail/{itemId}") { backStackEntry ->
val itemId = backStackEntry.arguments?.getString("itemId")?.toIntOrNull()
@@ -208,10 +224,7 @@ fun NavGraphBuilder.libraryGraph(navController: NavHostController) {
composable("dictionary_result/{entryId}") { backStackEntry ->
val entryId = backStackEntry.arguments?.getString("entryId")?.toIntOrNull()
if (entryId != null) {
DictionaryResultScreen(
entryId = entryId,
navController = navController,
)
DictionaryResultScreen(entryId = entryId, navController = navController)
} else {
Text("Error: Invalid Entry ID")
}
@@ -223,23 +236,16 @@ fun NavGraphBuilder.libraryGraph(navController: NavHostController) {
navController = navController,
showDueTodayOnly = showDueTodayOnly,
categoryId = categoryId,
onNavigateToItem = { item ->
navController.navigate("vocabulary_detail/${item.id}")
},
onNavigateToItem = { item -> navController.navigate("vocabulary_detail/${item.id}") },
onNavigateBack = { navController.popBackStack() },
enableNavigationButtons = true
)
}
composable("language_progress") {
LanguageProgressScreen(
navController = navController
)
LanguageJourneyScreen(navController = navController)
}
composable(NavigationRoutes.STATS_VOCABULARY_HEATMAP) {
VocabularyHeatmapScreen(
navController = navController,
)
VocabularyHeatmapScreen(navController = navController)
}
composable("vocabulary_list/{showDueTodayOnly}/{stage}") { backStackEntry ->
val showDueTodayOnly = backStackEntry.arguments?.getString("showDueTodayOnly")?.toBoolean() ?: false
@@ -247,14 +253,11 @@ fun NavGraphBuilder.libraryGraph(navController: NavHostController) {
val stage = stageString?.let {
if (it.equals("null", ignoreCase = true)) null else VocabularyStage.valueOf(it)
}
AllCardsListScreen(
navController = navController,
showDueTodayOnly = showDueTodayOnly,
stage = stage,
onNavigateToItem = { item ->
navController.navigate("vocabulary_detail/${item.id}")
},
onNavigateToItem = { item -> navController.navigate("vocabulary_detail/${item.id}") },
onNavigateBack = { navController.popBackStack() },
categoryId = 0,
enableNavigationButtons = true
@@ -267,22 +270,15 @@ fun NavGraphBuilder.libraryGraph(navController: NavHostController) {
navArgument("categories") { type = NavType.StringType; nullable = true },
navArgument("stages") { type = NavType.StringType; nullable = true },
navArgument("languages") { type = NavType.StringType; nullable = true },
navArgument("dailyOnly") {
type = NavType.BoolType
defaultValue = false
},
navArgument("dailyOnly") { type = NavType.BoolType; defaultValue = false }
)
) { backStackEntry ->
val arguments = backStackEntry.arguments
val dailyOnly = arguments?.getBoolean("dailyOnly") ?: false
val categoryIds = arguments?.getString("categories")
val stageNames = arguments?.getString("stages")
val languageIds = arguments?.getString("languages")
val dailyOnlyJson = "{\"dailyOnly\": $dailyOnly}"
VocabularyExerciseHostScreen(
categoryIdsAsJson = categoryIds,
stageNamesAsJson = stageNames,
@@ -292,13 +288,7 @@ fun NavGraphBuilder.libraryGraph(navController: NavHostController) {
navController = navController
)
}
composable(
route = "vocabulary_exercise/{dailyOnly}?",
arguments = listOf(
navArgument("dailyOnly") { type = NavType.BoolType },
)
) { _ ->
composable("vocabulary_exercise/{dailyOnly}?", arguments = listOf(navArgument("dailyOnly") { type = NavType.BoolType })) { _ ->
VocabularyExerciseHostScreen(
categoryIdsAsJson = null,
stageNamesAsJson = null,
@@ -308,34 +298,18 @@ fun NavGraphBuilder.libraryGraph(navController: NavHostController) {
dailyOnlyAsJson = "{\"dailyOnly\": true}"
)
}
composable(
"stage_detail/{stage}",
arguments = listOf(
navArgument("stage") {
type = NavType.EnumType(VocabularyStage::class.java)
}
)
)
{ backStackEntry ->
@Suppress("DEPRECATION") val stage = backStackEntry.arguments?.getSerializable("stage") as VocabularyStage
//NOTE: can ignore warning for now, once moved away from min SDK 28, use:
// val stage = backStackEntry.arguments?.getSerializable("stage", VocabularyStage::class.java)
StageDetailScreen(
navController = navController,
stage = stage
)
composable("stage_detail/{stage}", arguments = listOf(navArgument("stage") { type = NavType.EnumType(VocabularyStage::class.java) })) { backStackEntry ->
@Suppress("DEPRECATION")
val stage = backStackEntry.arguments?.getSerializable("stage") as VocabularyStage
StageDetailScreen(navController = navController, stage = stage)
}
composable("category_detail/{categoryId}") { backStackEntry ->
val categoryId = backStackEntry.arguments?.getString("categoryId")?.toIntOrNull()
if (categoryId != null) {
CategoryDetailScreen(
categoryId = categoryId,
onBackClick = { navController.popBackStack() },
onNavigateToItem = { item ->
navController.navigate("vocabulary_detail/${item.id}")
},
onNavigateToItem = { item -> navController.navigate("vocabulary_detail/${item.id}") },
navController = navController
)
}
@@ -343,37 +317,22 @@ fun NavGraphBuilder.libraryGraph(navController: NavHostController) {
composable("category_list_screen") {
CategoryListScreen(
onNavigateBack = { navController.popBackStack() },
onCategoryClicked = { categoryId ->
navController.navigate("category_detail/$categoryId")
}
onCategoryClicked = { categoryId -> navController.navigate("category_detail/$categoryId") }
)
}
composable(
route = "vocabulary_sorting?mode={mode}", // Route now accepts an optional 'mode'
arguments = listOf(
navArgument("mode") { // Define the argument
type = NavType.StringType
nullable = true
}
)
) { backStackEntry ->
composable("vocabulary_sorting?mode={mode}", arguments = listOf(navArgument("mode") { type = NavType.StringType; nullable = true })) { backStackEntry ->
VocabularySortingScreen(
navController = navController,
// Pass the argument to the screen
initialFilterMode = backStackEntry.arguments?.getString("mode")
)
}
composable("no_grammar_items") {
NoGrammarItemsScreen(
navController = navController
)
NoGrammarItemsScreen(navController = navController)
}
}
}
fun NavGraphBuilder.statsGraph(
navController: NavHostController,
) {
fun NavGraphBuilder.statsGraph(navController: NavHostController) {
navigation(
startDestination = "main_stats",
route = Screen.Stats.route
@@ -382,9 +341,7 @@ fun NavGraphBuilder.statsGraph(
StatsScreen(navController = navController)
}
composable("stats/vocabulary_sorting") {
VocabularySortingScreen(
navController = navController
)
VocabularySortingScreen(navController = navController)
}
composable("stats/vocabulary_list/{showDueTodayOnly}/{categoryId}") { backStackEntry ->
val showDueTodayOnly = backStackEntry.arguments?.getString("showDueTodayOnly")?.toBoolean() ?: false
@@ -393,22 +350,16 @@ fun NavGraphBuilder.statsGraph(
navController = navController,
showDueTodayOnly = showDueTodayOnly,
categoryId = categoryId,
onNavigateToItem = { item ->
navController.navigate("vocabulary_detail/${item.id}")
},
onNavigateToItem = { item -> navController.navigate("vocabulary_detail/${item.id}") },
onNavigateBack = { navController.popBackStack() },
enableNavigationButtons = true
)
}
composable(NavigationRoutes.STATS_LANGUAGE_PROGRESS) {
LanguageProgressScreen(
navController = navController
)
LanguageJourneyScreen(navController = navController)
}
composable(NavigationRoutes.STATS_VOCABULARY_HEATMAP) {
VocabularyHeatmapScreen(
navController = navController,
)
VocabularyHeatmapScreen(navController = navController)
}
composable("stats/vocabulary_list/{showDueTodayOnly}/{stage}") { backStackEntry ->
val showDueTodayOnly = backStackEntry.arguments?.getString("showDueTodayOnly")?.toBoolean() ?: false
@@ -416,14 +367,11 @@ fun NavGraphBuilder.statsGraph(
val stage = stageString?.let {
if (it.equals("null", ignoreCase = true)) null else VocabularyStage.valueOf(it)
}
AllCardsListScreen(
navController = navController,
showDueTodayOnly = showDueTodayOnly,
stage = stage,
onNavigateToItem = { item ->
navController.navigate("vocabulary_detail/${item.id}")
},
onNavigateToItem = { item -> navController.navigate("vocabulary_detail/${item.id}") },
onNavigateBack = { navController.popBackStack() },
categoryId = 0,
enableNavigationButtons = true
@@ -431,14 +379,11 @@ fun NavGraphBuilder.statsGraph(
}
composable("stats/category_detail/{categoryId}") { backStackEntry ->
val categoryId = backStackEntry.arguments?.getString("categoryId")?.toIntOrNull()
if (categoryId != null) {
CategoryDetailScreen(
categoryId = categoryId,
onBackClick = { navController.popBackStack() },
onNavigateToItem = { item ->
navController.navigate("vocabulary_detail/${item.id}")
},
onNavigateToItem = { item -> navController.navigate("vocabulary_detail/${item.id}") },
navController = navController
)
}
@@ -446,29 +391,14 @@ fun NavGraphBuilder.statsGraph(
composable("stats/category_list_screen") {
CategoryListScreen(
onNavigateBack = { navController.popBackStack() },
onCategoryClicked = { categoryId ->
navController.navigate("stats/category_detail/$categoryId")
}
onCategoryClicked = { categoryId -> navController.navigate("stats/category_detail/$categoryId") }
)
}
composable(
route = "stats/vocabulary_sorting?mode={mode}",
arguments = listOf(
navArgument("mode") {
type = NavType.StringType
nullable = true
}
)
) { backStackEntry ->
VocabularySortingScreen(
navController = navController,
initialFilterMode = backStackEntry.arguments?.getString("mode")
)
composable("stats/vocabulary_sorting?mode={mode}", arguments = listOf(navArgument("mode") { type = NavType.StringType; nullable = true })) { backStackEntry ->
VocabularySortingScreen(navController = navController, initialFilterMode = backStackEntry.arguments?.getString("mode"))
}
composable("stats/no_grammar_items") {
NoGrammarItemsScreen(
navController = navController
)
NoGrammarItemsScreen(navController = navController)
}
}
}
@@ -495,15 +425,16 @@ fun NavGraphBuilder.dictionaryGraph(navController: NavHostController) {
route = Screen.Dictionary.route
) {
composable("main_dictionary") {
MainDictionaryScreen(navController = navController)
DictionaryScreen(
navController = navController,
onEntryClick = { entry -> navController.navigate("dictionary_result/${entry.id}") },
onNavigateToOptions = { navController.navigate("dictionary_options") }
)
}
composable("dictionary_result/{entryId}") { backStackEntry ->
val entryId = backStackEntry.arguments?.getString("entryId")?.toIntOrNull()
if (entryId != null) {
DictionaryResultScreen(
entryId = entryId,
navController = navController,
)
DictionaryResultScreen(entryId = entryId, navController = navController)
} else {
Text("Error: Invalid Entry ID")
}
@@ -514,43 +445,39 @@ fun NavGraphBuilder.dictionaryGraph(navController: NavHostController) {
composable("etymology_result/{word}/{languageCode}") { backStackEntry ->
val word = backStackEntry.arguments?.getString("word") ?: ""
val languageCode = backStackEntry.arguments?.getString("languageCode")?.toIntOrNull() ?: 1
EtymologyResultScreen(
navController = navController,
word = word,
languageCode = languageCode
)
EtymologyResultScreen(navController = navController, word = word, languageCode = languageCode)
}
}
}
fun NavGraphBuilder.correctorGraph(navController: NavHostController) {
navigation(
startDestination = "main_corrector",
route = Screen.Corrector.route
) {
composable("main_corrector") {
CorrectionScreen(navController = navController)
}
}
}
@OptIn(androidx.compose.animation.ExperimentalAnimationApi::class)
fun NavGraphBuilder.exerciseGraph(
navController: NavHostController,
) {
fun NavGraphBuilder.exerciseGraph(navController: NavHostController) {
navigation(
startDestination = "main_exercise",
route = Screen.Exercises.route
) {
composable("main_exercise") {
MainExerciseScreen(
navController = navController,
)
MainExerciseScreen(navController = navController)
}
composable("exercise_session") {
ExerciseSessionScreen(
navController = navController,
)
ExerciseSessionScreen(navController = navController)
}
composable("youtube_exercise") {
YouTubeExerciseScreen(
navController = navController
)
YouTubeExerciseScreen(navController = navController)
}
composable("youtube_browse") {
YouTubeBrowserScreen(
navController = navController,
)
YouTubeBrowserScreen(navController = navController)
}
}
}
}

View File

@@ -16,13 +16,18 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import eu.gaudian.translator.R
import eu.gaudian.translator.view.composable.AppButton
import eu.gaudian.translator.view.composable.AppTopAppBar
@Composable
fun NoConnectionScreen(onSettingsClick: () -> Unit) {
fun NoConnectionScreen(onSettingsClick: () -> Unit, navController: NavController) {
AppTopAppBar(
title = "No Connection",
onNavigateBack = {navController.popBackStack()},
)
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center,
@@ -41,7 +46,7 @@ fun NoConnectionScreen(onSettingsClick: () -> Unit) {
textAlign = TextAlign.Center
)
AppButton(onClick = onSettingsClick, modifier = Modifier.padding(top = 16.dp)) {
Text(text = stringResource(id = R.string.settings_title_connection))
Text(text = "Configure Connection")
}
}
}

View File

@@ -1,8 +1,13 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.view.vocabulary
package eu.gaudian.translator.view.categories
import android.annotation.SuppressLint
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -12,22 +17,26 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
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.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@@ -42,20 +51,25 @@ import eu.gaudian.translator.model.TagCategory
import eu.gaudian.translator.model.VocabularyFilter
import eu.gaudian.translator.model.VocabularyItem
import eu.gaudian.translator.utils.findActivity
import eu.gaudian.translator.view.composable.AppCard
import eu.gaudian.translator.view.composable.AppIcons
import eu.gaudian.translator.view.composable.AppScaffold
import eu.gaudian.translator.view.composable.AppTopAppBar
import eu.gaudian.translator.view.composable.PrimaryButton
import eu.gaudian.translator.view.composable.SecondaryButton
import eu.gaudian.translator.view.dialogs.DeleteCategoryDialog
import eu.gaudian.translator.view.dialogs.DeleteItemsDialog
import eu.gaudian.translator.view.dialogs.EditCategoryDialog
import eu.gaudian.translator.view.vocabulary.widgets.CategoryProgressCircle
import eu.gaudian.translator.view.vocabulary.AllCardsListScreen
import eu.gaudian.translator.view.stats.widgets.CategoryProgressCircle
import eu.gaudian.translator.view.stats.widgets.ChartLegend
import eu.gaudian.translator.viewmodel.CategoryProgress
import eu.gaudian.translator.viewmodel.CategoryViewModel
import eu.gaudian.translator.viewmodel.ExportImportViewModel
import eu.gaudian.translator.viewmodel.ExportState
import eu.gaudian.translator.viewmodel.LanguageViewModel
import eu.gaudian.translator.viewmodel.ProgressViewModel
import eu.gaudian.translator.viewmodel.VocabularyViewModel
import kotlinx.coroutines.launch
@SuppressLint("ContextCastToActivity")
@Composable
@@ -71,12 +85,16 @@ fun CategoryDetailScreen(
val progressViewModel: ProgressViewModel = hiltViewModel(viewModelStoreOwner = activity)
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
val vocabularyViewModel: VocabularyViewModel = hiltViewModel(viewModelStoreOwner = activity)
val exportImportViewModel: ExportImportViewModel = hiltViewModel(viewModelStoreOwner = activity)
val category by categoryViewModel.getCategoryById(categoryId).collectAsState(initial = null)
val categoryProgressList by progressViewModel.categoryProgressList.collectAsState()
val categoryProgress = categoryProgressList.find { it.vocabularyCategory.id == categoryId }
val exportState by exportImportViewModel.exportState.collectAsState()
val context = LocalContext.current
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
val title = when (val cat = category) {
is TagCategory -> cat.name
is VocabularyFilter -> cat.name
@@ -115,8 +133,50 @@ fun CategoryDetailScreen(
val showDeleteItemsDialog by categoryViewModel.showDeleteItemsDialog.collectAsState(initial = false)
val showEditCategoryDialog by categoryViewModel.showEditCategoryDialog.collectAsState(initial = false)
// Handle export state changes
LaunchedEffect(exportState) {
when (exportState) {
is ExportState.Success -> {
// Create and launch share intent
val shareIntent = exportImportViewModel.createShareIntent()
if (shareIntent != null) {
context.startActivity(shareIntent)
}
exportImportViewModel.resetExportState()
}
is ExportState.Error -> {
scope.launch {
snackbarHostState.showSnackbar(
message = (exportState as ExportState.Error).message
)
}
exportImportViewModel.resetExportState()
}
else -> { /* Idle or Loading */ }
}
}
// Scroll state for animation
val listState = rememberLazyListState()
var isHeaderVisible by remember { mutableStateOf(true) }
var previousIndex by remember { mutableStateOf(0) }
var previousScrollOffset by remember { mutableStateOf(0) }
// Detect scroll direction to show/hide header (same as LibraryScreen)
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
.collect { (index, offset) ->
val isScrollingDown = index > previousIndex || (index == previousIndex && offset > previousScrollOffset)
val isAtTop = index == 0 && offset <= 4
isHeaderVisible = if (isAtTop) true else !isScrollingDown
previousIndex = index
previousScrollOffset = offset
}
}
AppScaffold(
modifier = modifier,
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
topBar = {
Column(
modifier = Modifier.background(MaterialTheme.colorScheme.surface)
@@ -137,21 +197,51 @@ fun CategoryDetailScreen(
modifier = Modifier.width(220.dp)
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.text_export_category)) },
text = {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("Export Category")
if (exportState is ExportState.Loading) {
CircularProgressIndicator(
modifier = Modifier.width(16.dp).height(16.dp),
strokeWidth = 2.dp
)
}
}
},
onClick = {
vocabularyViewModel.saveCategory(categoryId)
exportImportViewModel.exportCategory(categoryId)
showMenu = false
},
leadingIcon = { Icon(AppIcons.Share, contentDescription = null) }
leadingIcon = { Icon(AppIcons.Share, contentDescription = null) },
enabled = exportState !is ExportState.Loading
)
DropdownMenuItem(
text = { Text(stringResource(R.string.delete_items_category)) },
text = { Text("Delete Items") },
onClick = {
categoryViewModel.setShowDeleteItemsDialog(true, categoryId)
showMenu = false
},
leadingIcon = { Icon(AppIcons.Delete, contentDescription = null) }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.label_edit)) },
onClick = {
categoryViewModel.setShowEditCategoryDialog(true, categoryId)
showMenu = false
},
leadingIcon = { Icon(AppIcons.Edit, contentDescription = null) }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.label_delete)) },
onClick = {
categoryViewModel.setShowDeleteCategoryDialog(true, categoryId)
showMenu = false
},
leadingIcon = { Icon(AppIcons.Delete, contentDescription = null) }
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
@@ -159,22 +249,26 @@ fun CategoryDetailScreen(
)
)
// Category Header Card with Progress and Action Buttons
CategoryHeaderCard(
subtitle = subtitle,
categoryProgress = categoryProgress,
onStartExerciseClick = {
val categories = listOf(category)
val categoryIds = categories.joinToString(",") { it?.id.toString() }
navController.navigate("vocabulary_exercise/false?categories=$categoryIds")
},
onEditClick = {
categoryViewModel.setShowEditCategoryDialog(true, categoryId)
},
onDeleteClick = {
categoryViewModel.setShowDeleteCategoryDialog(true, categoryId)
}
)
// Category Header Card with Progress and Action Buttons (animated)
AnimatedVisibility(
visible = isHeaderVisible,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
CategoryHeaderCard(
subtitle = subtitle,
categoryProgress = categoryProgress,
onStartExerciseClick = {
navController.navigate("start_exercise?categoryId=$categoryId")
},
onEditClick = {
categoryViewModel.setShowEditCategoryDialog(true, categoryId)
},
onDeleteClick = {
categoryViewModel.setShowDeleteCategoryDialog(true, categoryId)
}
)
}
}
}
) { paddingValues ->
@@ -186,7 +280,8 @@ fun CategoryDetailScreen(
navController = navController,
isRemoveFromCategoryEnabled = category is TagCategory,
showTopBar = false,
enableNavigationButtons = true
enableNavigationButtons = true,
listState = listState
)
// Dialogs
@@ -222,15 +317,10 @@ fun CategoryHeaderCard(
onDeleteClick: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
AppCard(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
.padding(horizontal = 16.dp, vertical = 16.dp),
) {
Column(
modifier = Modifier
@@ -249,22 +339,26 @@ fun CategoryHeaderCard(
)
}
// Progress Circle
// Progress Circle - smaller size
if (categoryProgress != null) {
CategoryProgressCircle(
totalItems = categoryProgress.totalItems,
itemsCompleted = categoryProgress.itemsCompleted,
itemsInStages = categoryProgress.itemsInStages,
newItems = categoryProgress.newItems,
circleSize = 120.dp,
circleSize = 100.dp,
)
Spacer(modifier = Modifier.height(24.dp))
Spacer(modifier = Modifier.height(4.dp))
ChartLegend()
Spacer(modifier = Modifier.height(16.dp))
}
// Action Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
// Start Exercise Button (Primary)
PrimaryButton(
@@ -274,30 +368,6 @@ fun CategoryHeaderCard(
modifier = Modifier.weight(1f)
)
}
Spacer(modifier = Modifier.height(12.dp))
// Secondary Action Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
// Edit Button
SecondaryButton(
text = stringResource(R.string.label_edit),
icon = AppIcons.Edit,
onClick = onEditClick,
modifier = Modifier.weight(1f)
)
// Delete Button
SecondaryButton(
text = stringResource(R.string.label_delete),
icon = AppIcons.Delete,
onClick = onDeleteClick,
modifier = Modifier.weight(1f)
)
}
}
}
}

View File

@@ -1,6 +1,6 @@
@file:Suppress("AssignedValueIsNeverRead")
package eu.gaudian.translator.view.vocabulary
package eu.gaudian.translator.view.categories
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
@@ -44,8 +44,8 @@ import eu.gaudian.translator.view.composable.AppScaffold
import eu.gaudian.translator.view.composable.AppTopAppBar
import eu.gaudian.translator.view.dialogs.AddCategoryDialog
import eu.gaudian.translator.view.dialogs.DeleteMultipleCategoriesDialog
import eu.gaudian.translator.view.vocabulary.widgets.CategoryCircleType
import eu.gaudian.translator.view.vocabulary.widgets.CategoryProgressCircle
import eu.gaudian.translator.view.stats.widgets.CategoryCircleType
import eu.gaudian.translator.view.stats.widgets.CategoryProgressCircle
import eu.gaudian.translator.viewmodel.CategoryViewModel
import eu.gaudian.translator.viewmodel.ProgressViewModel
@@ -100,7 +100,7 @@ fun CategoryListScreen(
AppScaffold(
topBar = {
AppTopAppBar(
title = "TODO",
title = stringResource(R.string.label_all_categories),
navigationIcon = {
if (isSelectionMode) {
IconButton(onClick = {

View File

@@ -0,0 +1,124 @@
package eu.gaudian.translator.view.composable
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* A compact action card with an icon and label, designed for use in rows or grids.
* Used for quick action buttons like "Explore Packs", "Import CSV", etc.
*
* @param label The text label below the icon
* @param icon The icon to display
* @param onClick Callback when the card is clicked
* @param modifier Modifier for the card
* @param height The height of the card (default 120.dp)
*/
@Composable
fun AppActionCard(
label: String,
icon: ImageVector,
onClick: () -> Unit,
modifier: Modifier = Modifier,
height: Dp = 120.dp,
iconContainerSize: Dp = 48.dp,
iconSize: Dp = 24.dp
) {
AppCard(
modifier = modifier.height(height),
onClick = onClick
) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
CircularIconContainer(
imageVector = icon,
size = iconContainerSize,
iconSize = iconSize
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = label,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
}
}
}
/**
* A section header label with consistent styling.
* Used for section titles like "Recently Added", etc.
*
* @param text The section title text
* @param modifier Modifier for the text
*/
@Composable
fun SectionLabel(
text: String,
modifier: Modifier = Modifier
) {
Text(
text = text,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = modifier
)
}
/**
* A labeled section with an optional action button.
* Provides consistent header styling for sections with a title and optional action.
*
* @param title The section title
* @param modifier Modifier for the section header
* @param actionLabel Optional label for the action button
* @param onActionClick Optional callback for the action button
* @param content The content below the header
*/
@Composable
fun LabeledSection(
title: String,
modifier: Modifier = Modifier,
actionLabel: String? = null,
onActionClick: (() -> Unit)? = null,
content: @Composable () -> Unit
) {
Column(modifier = modifier) {
// Header row with title and optional action
if (actionLabel != null && onActionClick != null) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
SectionLabel(text = title)
androidx.compose.material3.TextButton(onClick = onActionClick) {
Text(actionLabel)
}
}
} else {
SectionLabel(text = title)
}
Spacer(modifier = Modifier.height(12.dp))
content()
}
}

View File

@@ -0,0 +1,249 @@
package eu.gaudian.translator.view.composable
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import eu.gaudian.translator.R
import eu.gaudian.translator.view.composable.ComponentDefaults.DefaultElevation
import eu.gaudian.translator.view.hints.Hint
import eu.gaudian.translator.view.hints.HintBottomSheet
import eu.gaudian.translator.view.hints.LocalShowHints
/**
* A styled card container for displaying content with a consistent floating look.
*
* @param modifier The modifier to be applied to the card.
* @param content The content to be displayed inside the card.
*/
@Composable
fun AppCard(
modifier: Modifier = Modifier,
title: String? = null,
icon: ImageVector? = null,
text: String? = null,
expandable: Boolean = false,
initiallyExpanded: Boolean = false,
onClick: (() -> Unit)? = null,
hintContent : Hint? = null,
content: @Composable ColumnScope.() -> Unit,
) {
var isExpanded by remember { mutableStateOf(initiallyExpanded) }
val showHints = LocalShowHints.current
val rotationState by animateFloatAsState(
targetValue = if (isExpanded) 180f else 0f,
label = "Chevron Rotation"
)
// 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
var showBottomSheet by remember { mutableStateOf(false) }
if (showBottomSheet) {
hintContent?.let {
HintBottomSheet(
onDismissRequest = { showBottomSheet = false },
content = it,
sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
)
)
}
}
Surface(
modifier = modifier
.fillMaxWidth()
.shadow(
DefaultElevation,
shape = ComponentDefaults.CardShape
)
.clip(ComponentDefaults.CardClipShape)
// Animate height changes when expanding/collapsing
.animateContentSize(),
shape = ComponentDefaults.CardShape,
color = MaterialTheme.colorScheme.surfaceContainer,
) {
Column {
// --- Header Row ---
if (hasHeader) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = canClickHeader) {
if (expandable) {
isExpanded = !isExpanded
}
onClick?.invoke()
}
.padding(ComponentDefaults.CardPadding),
verticalAlignment = Alignment.CenterVertically
) {
// 1. Optional Icon on the left
if (icon != null) {
Spacer(modifier = Modifier.width(8.dp))
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(16.dp))
}
// 2. Title and Text Column
Column(modifier = Modifier.weight(1f)) {
if (!title.isNullOrBlank()) {
Text(
text = title,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface
)
}
// Only show spacer if both title and text exist
if (!title.isNullOrBlank() && !text.isNullOrBlank()) {
Spacer(Modifier.size(4.dp))
}
if (!text.isNullOrBlank()) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
if (showHints && hintContent != null) {
IconButton(onClick = { showBottomSheet = true }) {
Icon(
imageVector = AppIcons.Help,
contentDescription = stringResource(R.string.show_hint),
tint = MaterialTheme.colorScheme.secondary
)
}
}
// 3. Expand Chevron (Far right)
if (expandable) {
Icon(
imageVector = AppIcons.ArrowDropDown,
contentDescription = if (isExpanded) "Collapse" else "Expand",
modifier = Modifier.rotate(rotationState),
tint = MaterialTheme.colorScheme.onSurface
)
}
}
}
// --- Content Area ---
if (!expandable || isExpanded) {
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
)
if (!hasHeader && onClick != null) {
Column(
modifier = contentModifier.clickable { onClick() },
content = content
)
} else {
Column(
modifier = contentModifier,
content = content
)
}
}
}
}
}
@Preview
@Composable
fun AppCardPreview() {
AppCard {
Text(stringResource(R.string.this_is_the_content_inside_the_card))
PrimaryButton(onClick = { }, text = stringResource(R.string.label_continue))
}
}
@Preview(showBackground = true)
@Composable
fun AppCardPreview2() {
MaterialTheme {
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// 1. Expandable Card (Initially Collapsed)
AppCard(
title = "Advanced Settings",
text = "Click to reveal more options",
expandable = true,
initiallyExpanded = false
) {
Text("Here are some hidden settings.")
Text("They are only visible when expanded.")
}
// 2. Expandable Card (Initially Expanded)
AppCard(
title = "Translation History",
text = "Recent items",
expandable = true,
initiallyExpanded = true
) {
Text("• Hello -> Hallo")
Text("• World -> Welt")
Text("• Sun -> Sonne")
}
// 3. Static Card (No Title/Expand logic - Legacy behavior)
AppCard {
Text("This is a standard card without a header.")
}
}
}
}

View File

@@ -52,6 +52,7 @@ data class FabMenuItem(
)
@Deprecated("We don't want to use floating butto menus anymore")
@Composable
fun AppFabMenu(
items: List<FabMenuItem>,

View File

@@ -0,0 +1,81 @@
package eu.gaudian.translator.view.composable
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* A reusable icon container that displays an icon inside a shaped background.
* Used throughout the app for consistent icon presentation in cards, buttons, and action items.
*
* @param imageVector The icon to display
* @param modifier Modifier to be applied to the container
* @param size The size of the container (default 40.dp)
* @param iconSize The size of the icon itself (default 24.dp)
* @param shape The shape of the container (default RoundedCornerShape(12.dp))
* @param backgroundColor Background color of the container
* @param iconTint Tint color for the icon
*/
@Composable
fun AppIconContainer(
imageVector: ImageVector,
modifier: Modifier = Modifier,
size: Dp = 40.dp,
iconSize: Dp = 24.dp,
shape: androidx.compose.ui.graphics.Shape = RoundedCornerShape(12.dp),
backgroundColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.surfaceVariant,
iconTint: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.primary,
contentDescription: String? = null
) {
Box(
modifier = modifier
.size(size)
.clip(shape)
.background(backgroundColor),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = imageVector,
contentDescription = contentDescription,
tint = iconTint,
modifier = Modifier.size(iconSize)
)
}
}
/**
* A circular variant of AppIconContainer.
* Convenience wrapper for circular icon containers.
*/
@Composable
fun CircularIconContainer(
imageVector: ImageVector,
modifier: Modifier = Modifier,
size: Dp = 48.dp,
iconSize: Dp = 24.dp,
backgroundColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.surfaceVariant,
iconTint: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.primary,
contentDescription: String? = null
) {
AppIconContainer(
imageVector = imageVector,
modifier = modifier,
size = size,
iconSize = iconSize,
shape = CircleShape,
backgroundColor = backgroundColor,
iconTint = iconTint,
contentDescription = contentDescription
)
}

View File

@@ -115,7 +115,7 @@ fun AppOutlinedTextField(
OutlinedTextField(
value = value,
onValueChange = onValueChange,
modifier = modifier.fillMaxWidth(),
modifier = modifier,
label = label,
trailingIcon = finalTrailingIcon,
shape = ComponentDefaults.DefaultShape,

View File

@@ -49,6 +49,7 @@ interface TabItem {
val title: String
val icon: ImageVector
}
@Deprecated("Migrate to new (like used in LibraryScreen")
@SuppressLint("UnusedBoxWithConstraintsScope", "LocalContextResourcesRead", "DiscouragedApi",
"SuspiciousIndentation"
)

View File

@@ -0,0 +1,74 @@
package eu.gaudian.translator.view.composable
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
/**
* A styled filled text input field.
* Different from AppOutlinedTextField - this uses a filled background style.
*
* @param value The input text to be shown in the text field.
* @param onValueChange The callback that is triggered when the input service updates the text.
* @param modifier The modifier to be applied to the text field.
* @param placeholder The placeholder text to display when the field is empty.
* @param enabled Whether the text field is enabled.
* @param readOnly Whether the text field is read-only.
* @param singleLine Whether the text field is single line.
* @param minLines Minimum number of lines.
* @param maxLines Maximum number of lines.
*/
@Composable
fun AppTextField(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
placeholder: String? = null,
enabled: Boolean = true,
readOnly: Boolean = false,
singleLine: Boolean = true,
minLines: Int = 1,
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
) {
val cornerRadius = 12.dp
TextField(
value = value,
onValueChange = onValueChange,
modifier = modifier.fillMaxWidth(),
placeholder = placeholder?.let {
{
Text(
text = it,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
}
},
shape = RoundedCornerShape(cornerRadius),
colors = TextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.surface,
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
cursorColor = MaterialTheme.colorScheme.primary,
focusedTextColor = MaterialTheme.colorScheme.onSurface,
unfocusedTextColor = MaterialTheme.colorScheme.onSurface
),
singleLine = singleLine,
minLines = minLines,
maxLines = maxLines,
enabled = enabled,
readOnly = readOnly,
leadingIcon = leadingIcon,
trailingIcon = trailingIcon
)
}

View File

@@ -49,7 +49,7 @@ fun AppTopAppBar(
navigationIcon: @Composable (() -> Unit)? = null,
actions: @Composable RowScope.() -> Unit = {},
colors: TopAppBarColors = TopAppBarDefaults.topAppBarColors(),
hintContent: Hint? = null
hint: Hint? = null
) {
val sheetState = rememberModalBottomSheetState()
var showBottomSheet by remember { mutableStateOf(false) }
@@ -61,7 +61,7 @@ fun AppTopAppBar(
colors = colors,
title = {
val showHints = LocalShowHints.current
if (showHints && hintContent != null) {
if (showHints && hint != null) {
// Simplified row: keeps the title and hint icon neatly centered together
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -114,7 +114,7 @@ fun AppTopAppBar(
)
if (showBottomSheet) {
hintContent?.let {
hint?.let {
HintBottomSheet(
onDismissRequest = {
@Suppress("AssignedValueIsNeverRead")

View File

@@ -11,6 +11,7 @@ import androidx.compose.animation.core.spring
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -77,6 +78,7 @@ sealed class Screen(
object Settings : Screen("settings", R.string.title_settings, AppIcons.SettingsFilled, AppIcons.SettingsOutlined)
object More : Screen("more", R.string.label_more, AppIcons.MoreVert, AppIcons.MoreHorizontal)
object Dictionary : Screen("dictionary", R.string.label_dictionary, AppIcons.DictionaryFilled, AppIcons.DictionaryOutlined)
object Corrector : Screen("corrector", R.string.title_corrector, AppIcons.SpellCheck, AppIcons.SpellCheck)
object Exercises : Screen("exercises", R.string.label_exercises, AppIcons.DictionaryFilled, AppIcons.DictionaryOutlined)
companion object {
@@ -88,6 +90,7 @@ sealed class Screen(
val items = mutableListOf<Screen>()
items.add(Translation)
items.add(Dictionary)
items.add(Corrector)
items.add(Settings)
if (showExperimental) {
items.add(Exercises)
@@ -258,7 +261,7 @@ fun BottomNavigationBar(
.background(
brush = Brush.radialGradient(
colors = listOf(
MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f),
Color.Transparent
)
),
@@ -271,6 +274,12 @@ fun BottomNavigationBar(
modifier = Modifier
.size(playButtonSize)
.clip(CircleShape)
// CHANGED: Added a border to give the button definition
.border(
width = 4.dp, // Adjust this thickness to your liking
color = MaterialTheme.colorScheme.surfaceVariant, // Creates a nice "cutout" separation
shape = CircleShape
)
.background(MaterialTheme.colorScheme.primaryContainer)
.clickable {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
@@ -281,7 +290,7 @@ fun BottomNavigationBar(
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = "Play",
tint = Color.White,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(32.dp)
)
}
@@ -400,4 +409,4 @@ fun BottomNavigationBarPreview() {
)
}
}
}
}

View File

@@ -2,23 +2,17 @@
package eu.gaudian.translator.view.composable
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonColors
@@ -28,26 +22,19 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.vector.ImageVector
@@ -57,10 +44,6 @@ import androidx.compose.ui.unit.dp
import eu.gaudian.translator.R
import eu.gaudian.translator.ui.theme.ThemePreviews
import eu.gaudian.translator.ui.theme.semanticColors
import eu.gaudian.translator.view.composable.ComponentDefaults.DefaultElevation
import eu.gaudian.translator.view.hints.Hint
import eu.gaudian.translator.view.hints.HintBottomSheet
import eu.gaudian.translator.view.hints.LocalShowHints
object ComponentDefaults {
@@ -90,218 +73,6 @@ object ComponentDefaults {
const val ALPHA_LOW = 0.3f
}
/**
* A styled card container for displaying content with a consistent floating look.
*
* @param modifier The modifier to be applied to the card.
* @param content The content to be displayed inside the card.
*/
@Composable
fun AppCard(
modifier: Modifier = Modifier,
title: String? = null,
icon: ImageVector? = null,
text: String? = null,
expandable: Boolean = false,
initiallyExpanded: Boolean = false,
onClick: (() -> Unit)? = null,
hintContent : Hint? = null,
content: @Composable ColumnScope.() -> Unit,
) {
var isExpanded by remember { mutableStateOf(initiallyExpanded) }
val showHints = LocalShowHints.current
val rotationState by animateFloatAsState(
targetValue = if (isExpanded) 180f else 0f,
label = "Chevron Rotation"
)
// 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
var showBottomSheet by remember { mutableStateOf(false) }
if (showBottomSheet) {
hintContent?.let {
HintBottomSheet(
onDismissRequest = { showBottomSheet = false },
content = it,
sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
)
)
}
}
Surface(
modifier = modifier
.fillMaxWidth()
.shadow(
DefaultElevation,
shape = ComponentDefaults.CardShape
)
.clip(ComponentDefaults.CardClipShape)
// Animate height changes when expanding/collapsing
.animateContentSize(),
shape = ComponentDefaults.CardShape,
color = MaterialTheme.colorScheme.surfaceContainer,
) {
Column {
// --- Header Row ---
if (hasHeader) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = canClickHeader) {
if (expandable) {
isExpanded = !isExpanded
}
onClick?.invoke()
}
.padding(ComponentDefaults.CardPadding),
verticalAlignment = Alignment.CenterVertically
) {
// 1. Optional Icon on the left
if (icon != null) {
Spacer(modifier = Modifier.width(8.dp))
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(16.dp))
}
// 2. Title and Text Column
Column(modifier = Modifier.weight(1f)) {
if (!title.isNullOrBlank()) {
Text(
text = title,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface
)
}
// Only show spacer if both title and text exist
if (!title.isNullOrBlank() && !text.isNullOrBlank()) {
Spacer(Modifier.size(4.dp))
}
if (!text.isNullOrBlank()) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
if (showHints && hintContent != null) {
IconButton(onClick = { showBottomSheet = true }) {
Icon(
imageVector = AppIcons.Help,
contentDescription = stringResource(R.string.show_hint),
tint = MaterialTheme.colorScheme.secondary
)
}
}
// 3. Expand Chevron (Far right)
if (expandable) {
Icon(
imageVector = AppIcons.ArrowDropDown,
contentDescription = if (isExpanded) "Collapse" else "Expand",
modifier = Modifier.rotate(rotationState),
tint = MaterialTheme.colorScheme.onSurface
)
}
}
}
// --- Content Area ---
if (!expandable || isExpanded) {
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
)
if (!hasHeader && onClick != null) {
Column(
modifier = contentModifier.clickable { onClick() },
content = content
)
} else {
Column(
modifier = contentModifier,
content = content
)
}
}
}
}
}
@Preview
@Composable
fun AppCardPreview() {
AppCard {
Text(stringResource(R.string.this_is_the_content_inside_the_card))
PrimaryButton(onClick = { }, text = stringResource(R.string.label_continue))
}
}
@Preview(showBackground = true)
@Composable
fun AppCardPreview2() {
MaterialTheme {
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// 1. Expandable Card (Initially Collapsed)
AppCard(
title = "Advanced Settings",
text = "Click to reveal more options",
expandable = true,
initiallyExpanded = false
) {
Text("Here are some hidden settings.")
Text("They are only visible when expanded.")
}
// 2. Expandable Card (Initially Expanded)
AppCard(
title = "Translation History",
text = "Recent items",
expandable = true,
initiallyExpanded = true
) {
Text("• Hello -> Hallo")
Text("• World -> Welt")
Text("• Sun -> Sonne")
}
// 3. Static Card (No Title/Expand logic - Legacy behavior)
AppCard {
Text("This is a standard card without a header.")
}
}
}
}
/**
* The primary button for the most important actions.
*
@@ -636,6 +407,7 @@ fun WrongOutlinedButtonPreview(){
WrongOutlinedButton(onClick = { }, text = stringResource(R.string.label_continue))
}
//This is basically just a wrapper for screens to control width (tablet mode) etc.
@Composable
fun AppOutlinedCard(
modifier: Modifier = Modifier,

View File

@@ -0,0 +1,188 @@
@file:Suppress("AssignedValueIsNeverRead", "HardCodedStringLiteral")
package eu.gaudian.translator.view.composable
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import eu.gaudian.translator.R
import eu.gaudian.translator.model.Language
import eu.gaudian.translator.model.VocabularyItem
import eu.gaudian.translator.utils.StatusMessageService
import eu.gaudian.translator.viewmodel.LanguageViewModel
@Composable
fun CsvImportDialog(
showDialog: Boolean,
parsedTable: List<List<String>>,
languageViewModel: LanguageViewModel,
onDismiss: () -> Unit,
onImport: (List<VocabularyItem>) -> Unit,
statusMessageService: StatusMessageService
) {
if (!showDialog) return
var selectedColFirst by remember { mutableIntStateOf(0) }
var selectedColSecond by remember { mutableIntStateOf(1.coerceAtMost((parsedTable.maxOfOrNull { it.size } ?: 1) - 1)) }
var skipHeader by remember { mutableStateOf(true) }
var selectedLangFirst by remember { mutableStateOf<Language?>(null) }
var selectedLangSecond by remember { mutableStateOf<Language?>(null) }
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)
AppDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.label_import_table_csv_excel)) }
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
val columnCount = parsedTable.maxOfOrNull { it.size } ?: 0
// First Column Selection
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 }
)
}
}
}
// Second Column Selection
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 }
)
}
}
}
// Language Selection
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 }
)
}
}
// Skip Header Checkbox
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = skipHeader, onCheckedChange = { skipHeader = it })
Spacer(modifier = Modifier.width(4.dp))
Text(stringResource(R.string.label_header_row))
}
// Preview
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))
// Row Count
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))
// Action Buttons
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp),
horizontalArrangement = Arrangement.End
) {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.label_cancel))
}
Spacer(modifier = Modifier.width(8.dp))
PrimaryButton(
onClick = {
if (selectedColFirst == selectedColSecond) {
statusMessageService.showErrorMessage(errorSelectTwoColumns)
return@PrimaryButton
}
val langA = selectedLangFirst
val langB = selectedLangSecond
if (langA == null || langB == null) {
statusMessageService.showErrorMessage(errorSelectLanguages)
return@PrimaryButton
}
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@PrimaryButton
}
onImport(items)
},
text = stringResource(R.string.label_import)
)
}
}
}
}

View File

@@ -0,0 +1,168 @@
package eu.gaudian.translator.view.dialogs
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import eu.gaudian.translator.R
import eu.gaudian.translator.ui.theme.ThemePreviews
import eu.gaudian.translator.view.composable.AppDialog
import eu.gaudian.translator.view.composable.AppOutlinedTextField
import eu.gaudian.translator.view.composable.AppSlider
import kotlin.math.roundToInt
@Composable
fun RequestMorePackDialog(
onDismiss: () -> Unit,
) {
val context = LocalContext.current
var topic by remember { mutableStateOf("") }
var langFrom by remember { mutableStateOf("") }
var langTo by remember { mutableStateOf("") }
var amount by remember { mutableFloatStateOf(50f) }
AppDialog(
onDismissRequest = onDismiss,
title = { Text("Request a Pack", fontWeight = FontWeight.Bold) },
) {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
stringResource(R.string.text_request_pack_desc),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
stringResource(R.string.label_topic),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold
)
AppOutlinedTextField(
value = topic,
onValueChange = { topic = it },
placeholder = { Text("e.g. Travel, Business, Cooking…") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Row(
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
stringResource(R.string.label_languages),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold
)
Text(
stringResource(R.string.label_optional),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
AppOutlinedTextField(
value = langFrom,
onValueChange = { langFrom = it },
placeholder = { Text(stringResource(R.string.label_from)) },
singleLine = true,
modifier = Modifier.weight(1f)
)
AppOutlinedTextField(
value = langTo,
onValueChange = { langTo = it },
placeholder = { Text(stringResource(R.string.label_to)) },
singleLine = true,
modifier = Modifier.weight(1f)
)
}
Text(
"Approx. word count: ~${amount.roundToInt()} words",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold
)
AppSlider(
value = amount,
onValueChange = { amount = it },
valueRange = 10f..200f,
steps = 18,
modifier = Modifier.fillMaxWidth()
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.label_cancel)) }
TextButton(
enabled = topic.isNotBlank(),
onClick = {
val subject = "Polly Pack Request $topic"
val langPart = buildString {
val from = langFrom.trim()
val to = langTo.trim()
if (from.isNotBlank() || to.isNotBlank()) {
append("Languages: ${from.ifBlank { "?" }} → ${to.ifBlank { "?" }}\n")
}
}
val body = buildString {
appendLine("Hey Jonas,")
appendLine()
appendLine("Please add the following vocabulary pack to Polly:")
appendLine()
appendLine("Topic: $topic")
if (langPart.isNotBlank()) append(langPart)
appendLine("Word count: ~${amount.roundToInt()} words")
appendLine()
appendLine("Thank you!")
}
val intent = android.content.Intent(android.content.Intent.ACTION_SENDTO).apply {
data = "mailto:play@gaudian.eu".toUri()
putExtra(android.content.Intent.EXTRA_EMAIL, arrayOf("play@gaudian.eu"))
putExtra(android.content.Intent.EXTRA_SUBJECT, subject)
putExtra(android.content.Intent.EXTRA_TEXT, body)
}
context.startActivity(intent)
onDismiss()
}
) {
Text(
stringResource(R.string.label_send_request),
fontWeight = FontWeight.Bold
)
}
}
}
}
}
@ThemePreviews
@Composable
fun RequestMorePackDialogPreview() {
RequestMorePackDialog(
onDismiss = {}
)
}

View File

@@ -63,7 +63,7 @@ fun VocabularyReviewScreen(
topBar = {
AppTopAppBar(
title = stringResource(R.string.found_items),
hintContent = HintDefinition.REVIEW.hint()
hint = HintDefinition.REVIEW.hint()
)
},
) { paddingValues ->

View File

@@ -60,12 +60,16 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavController
import eu.gaudian.translator.R
import eu.gaudian.translator.ui.theme.semanticColors
import eu.gaudian.translator.utils.findActivity
import eu.gaudian.translator.view.composable.AppButton
import eu.gaudian.translator.view.composable.AppIcons
import eu.gaudian.translator.view.composable.AppOutlinedButton
import eu.gaudian.translator.view.composable.AppSwitch
import eu.gaudian.translator.view.composable.AppTopAppBar
import eu.gaudian.translator.view.composable.DictionaryLanguageDropDown
import eu.gaudian.translator.view.composable.DropdownDefaults
import eu.gaudian.translator.view.composable.LargeDropdownMenuItem
@@ -73,12 +77,15 @@ import eu.gaudian.translator.viewmodel.CorrectionViewModel
import eu.gaudian.translator.viewmodel.LanguageViewModel
import kotlinx.coroutines.launch
// 1. STATEFUL COMPONENT (Connects to ViewModels)
@Composable
fun CorrectionScreen(
correctionViewModel: CorrectionViewModel,
languageViewModel: LanguageViewModel
navController: NavController
) {
val activity = LocalContext.current.findActivity()
val correctionViewModel: CorrectionViewModel = hiltViewModel(activity)
val languageViewModel : LanguageViewModel = hiltViewModel(activity)
val textFieldValue by correctionViewModel.textFieldValue.collectAsState()
val explanation by correctionViewModel.explanation.collectAsState()
val isLoading by correctionViewModel.isLoading.collectAsState()
@@ -89,6 +96,15 @@ fun CorrectionScreen(
val successColor = MaterialTheme.semanticColors.success
Column(){
AppTopAppBar(
title = stringResource(R.string.label_correction),
onNavigateBack = {
navController.popBackStack()
},
)
CorrectionScreenContent(
textFieldValue = textFieldValue,
explanation = explanation,
@@ -113,6 +129,7 @@ fun CorrectionScreen(
)
}
)
}
}
// 2. STATELESS COMPONENT (Handles UI Layout)
@@ -304,7 +321,6 @@ fun CorrectionScreenContent(
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
@@ -575,4 +591,4 @@ private fun CorrectionScreenResultsPreview() {
}
)
}
}
}

View File

@@ -342,10 +342,12 @@ fun DictionarySimpleTopBar(
languageName: String?,
onNavigateBack: () -> Unit
) {
AppTopAppBar(
title = "TODO",
onNavigateBack = onNavigateBack
)
word?.let {
AppTopAppBar(
title = it,
onNavigateBack = onNavigateBack
)
}
}
@Composable

View File

@@ -1,12 +1,16 @@
package eu.gaudian.translator.view.dictionary
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController
import eu.gaudian.translator.R
import eu.gaudian.translator.utils.findActivity
import eu.gaudian.translator.view.composable.AppTopAppBar
import eu.gaudian.translator.viewmodel.DictionaryViewModel
import eu.gaudian.translator.viewmodel.LanguageViewModel
@@ -20,15 +24,21 @@ fun DictionaryScreen(
val dictionaryViewModel: DictionaryViewModel = hiltViewModel(viewModelStoreOwner = activity)
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
Column {
AppTopAppBar(
title = stringResource(R.string.label_dictionary),
onNavigateBack = { navController.popBackStack() }
)
// Use the new refactored component
DictionaryScreenContent(
navController = navController,
onEntryClick = onEntryClick,
dictionaryViewModel = dictionaryViewModel,
languageViewModel = languageViewModel,
onNavigateToOptions = onNavigateToOptions
)
// Use the new refactored component
DictionaryScreenContent(
navController = navController,
onEntryClick = onEntryClick,
dictionaryViewModel = dictionaryViewModel,
languageViewModel = languageViewModel,
onNavigateToOptions = onNavigateToOptions
)
}
}
@Preview
@@ -40,4 +50,4 @@ fun DictionaryScreenPreview() {
onEntryClick = {},
onNavigateToOptions = {}
)
}
}

View File

@@ -93,7 +93,7 @@ fun EtymologyResultScreen(
AppScaffold(
topBar = {
AppTopAppBar(
title = "TODO",
title = "Result",
onNavigateBack = { navController.popBackStack() },
actions = {
etymologyData?.let { data ->

View File

@@ -1,106 +0,0 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.view.dictionary
import androidx.compose.foundation.layout.Column
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.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController
import eu.gaudian.translator.R
import eu.gaudian.translator.ui.theme.ThemePreviews
import eu.gaudian.translator.utils.findActivity
import eu.gaudian.translator.view.LocalConnectionConfigured
import eu.gaudian.translator.view.NoConnectionScreen
import eu.gaudian.translator.view.composable.AppIcons
import eu.gaudian.translator.view.composable.AppTabLayout
import eu.gaudian.translator.view.composable.Screen
import eu.gaudian.translator.view.composable.TabItem
import eu.gaudian.translator.view.settings.SettingsRoutes
import eu.gaudian.translator.viewmodel.CorrectionViewModel
import eu.gaudian.translator.viewmodel.DictionaryViewModel
import eu.gaudian.translator.viewmodel.LanguageViewModel
@Composable
private fun getDictionaryTabs(): List<TabItem> {
return listOf(
DictionaryTab(stringResource(R.string.label_dictionary), AppIcons.Dictionary),
DictionaryTab(stringResource(R.string.title_corrector), AppIcons.Check)
)
}
private data class DictionaryTab(override val title: String, override val icon: ImageVector) :
TabItem
@Composable
fun MainDictionaryScreen(
navController: NavController
) {
val activity = LocalContext.current.findActivity()
val dictionaryViewModel: DictionaryViewModel = hiltViewModel(viewModelStoreOwner = activity)
val correctionViewModel: CorrectionViewModel = hiltViewModel(viewModelStoreOwner = activity)
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
val dictionaryTabs = getDictionaryTabs()
val connectionConfigured = LocalConnectionConfigured.current
if (!connectionConfigured) {
NoConnectionScreen(onSettingsClick = {navController.navigate(SettingsRoutes.API_KEY)})
return
}
var selectedTab by remember { mutableStateOf(dictionaryTabs[0]) }
Column {
AppTabLayout(
tabs = dictionaryTabs,
selectedTab = selectedTab,
onTabSelected = { selectedTab = it },
onNavigateBack = {
if (!navController.popBackStack()) {
navController.navigate(Screen.Home.route) {
launchSingleTop = true
restoreState = false
}
}
}
)
when (selectedTab) {
dictionaryTabs[0] -> DictionaryScreen(
navController = navController,
onEntryClick = { entry ->
// Set flag indicating navigation is from external source (not DictionaryResultScreen)
dictionaryViewModel.setNavigatingFromDictionaryResult(false)
navController.navigate("dictionary_result/${entry.id}")
},
onNavigateToOptions = {
navController.navigate("dictionary_options")
}
)
dictionaryTabs[1] -> CorrectionScreen(
correctionViewModel = correctionViewModel,
languageViewModel = languageViewModel
)
}
}
}
@ThemePreviews
@Composable
fun DictionaryHostScreenPreview() {
val navController = rememberNavController()
MainDictionaryScreen(
navController = navController,
)
}

View File

@@ -1,4 +1,4 @@
package eu.gaudian.translator.view.vocabulary
package eu.gaudian.translator.view.exercises
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -23,6 +23,8 @@ import eu.gaudian.translator.view.composable.AppOutlinedButton
import eu.gaudian.translator.view.composable.AppOutlinedTextField
import eu.gaudian.translator.view.composable.CorrectButton
import eu.gaudian.translator.view.composable.WrongButton
import eu.gaudian.translator.view.vocabulary.VocabularyExerciseAction
import eu.gaudian.translator.view.vocabulary.VocabularyExerciseState
@Composable
fun ExerciseControls(

View File

@@ -1,4 +1,4 @@
package eu.gaudian.translator.view.vocabulary
package eu.gaudian.translator.view.exercises
import androidx.compose.animation.core.animateFloatAsState

View File

@@ -26,7 +26,8 @@ enum class HintDefinition(
REVIEW("review_hint", R.string.review_intro),
SORTING("sorting_hint", R.string.sorting_hint_title),
TRANSLATION("translation_hint", R.string.hint_translate_how_it_works),
VOCABULARY_PROGRESS("vocabulary_progress_hint", R.string.hint_vocabulary_progress_hint_title);
VOCABULARY_PROGRESS("vocabulary_progress_hint", R.string.hint_vocabulary_progress_hint_title),
EXPLORE_PACKS("explore_packs_hint", R.string.hint_explore_packs_title);
/** Creates the Hint data class for this hint definition. */
@Composable

View File

@@ -0,0 +1,168 @@
package eu.gaudian.translator.view.home
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import eu.gaudian.translator.R
import eu.gaudian.translator.utils.findActivity
import eu.gaudian.translator.view.NavigationRoutes
import eu.gaudian.translator.view.composable.AppButton
import eu.gaudian.translator.view.composable.AppScaffold
import eu.gaudian.translator.view.composable.AppTopAppBar
import eu.gaudian.translator.view.library.VocabularyCard
import eu.gaudian.translator.viewmodel.LanguageViewModel
import eu.gaudian.translator.viewmodel.VocabularyViewModel
@Composable
fun DailyReviewScreen(
navController: NavHostController,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val activity = context.findActivity()
val vocabularyViewModel: VocabularyViewModel = hiltViewModel(viewModelStoreOwner = activity)
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
val dueTodayItems by vocabularyViewModel.dueTodayItems.collectAsStateWithLifecycle(initialValue = emptyList())
val allLanguages by languageViewModel.allLanguages.collectAsStateWithLifecycle(initialValue = emptyList())
val listState = rememberLazyListState()
AppScaffold(
topBar = {
AppTopAppBar(
title = stringResource(R.string.label_daily_review),
onNavigateBack = { navController.popBackStack() }
)
},
modifier = modifier.fillMaxSize()
) { paddingValues ->
if (dueTodayItems.isEmpty()) {
Box(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
modifier = Modifier.size(200.dp),
painter = painterResource(id = R.drawable.ic_nothing_found),
contentDescription = null
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.no_items_due_for_review),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
}
} else {
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(16.dp)
) {
items(
items = dueTodayItems,
key = { it.id }
) { item ->
VocabularyCard(
item = item,
allLanguages = allLanguages,
isSelected = false,
onItemClick = {
vocabularyViewModel.setNavigationContext(dueTodayItems, item.id)
navController.navigate("${NavigationRoutes.VOCABULARY_DETAIL}/${item.id}")
},
onItemLongClick = { },
onDeleteClick = { }
)
}
// Add spacing at the bottom for the button
item {
Spacer(modifier = Modifier.height(80.dp))
}
}
// Start Exercise Button (fixed at bottom)
Box(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(paddingValues)
.padding(24.dp)
) {
AppButton(
onClick = {
navController.navigate(NavigationRoutes.START_EXERCISE_DAILY)
},
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
enabled = dueTodayItems.isNotEmpty(),
shape = RoundedCornerShape(28.dp)
) {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.label_start_exercise_2d, dueTodayItems.size),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.width(8.dp))
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = stringResource(R.string.cd_play),
modifier = Modifier.size(20.dp)
)
}
}
}
}
}
}
}

View File

@@ -5,8 +5,10 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -27,7 +29,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -47,9 +48,10 @@ import eu.gaudian.translator.R
import eu.gaudian.translator.utils.findActivity
import eu.gaudian.translator.view.NavigationRoutes
import eu.gaudian.translator.view.composable.AppCard
import eu.gaudian.translator.view.composable.LabeledSection
import eu.gaudian.translator.view.composable.Screen
import eu.gaudian.translator.view.settings.SettingsRoutes
import eu.gaudian.translator.view.vocabulary.widgets.WeeklyActivityChartWidget
import eu.gaudian.translator.view.stats.widgets.WeeklyActivityChartWidget
import eu.gaudian.translator.viewmodel.ProgressViewModel
@Composable
@@ -62,6 +64,7 @@ fun HomeScreen(
val streak by viewModel.streak.collectAsState()
val dailyGoal by viewModel.dailyGoal.collectAsState()
val todayCompletedCount by viewModel.todayCompletedCount.collectAsState()
val dueTodayCount by viewModel.dueTodayCount.collectAsState()
// Calculate daily goal progress
val progress = if (dailyGoal > 0) {
@@ -95,13 +98,12 @@ fun HomeScreen(
)
}
item {
//TODO replace with actual implementation
@Suppress("HardCodedStringLiteral")
ActionCard(
title = "Daily Review",
subtitle = "42 words need attention",
title = stringResource(R.string.label_daily_review),
subtitle = stringResource(R.string.desc_daily_review_due, dueTodayCount),
icon = Icons.Default.Psychology,
contentColor = MaterialTheme.colorScheme.onPrimary
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
onClick = { navController.navigate(NavigationRoutes.DAILY_REVIEW) }
)
}
item {
@@ -146,6 +148,9 @@ fun TopProfileSection(navController: NavHostController, context: Context) {
)
}
Spacer(modifier = Modifier.width(8.dp))
IconButton(
onClick = { navController.navigate(Screen.Settings.route) },
modifier = Modifier
@@ -158,6 +163,7 @@ fun TopProfileSection(navController: NavHostController, context: Context) {
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.width(8.dp))
}
}
@@ -352,20 +358,12 @@ fun WeeklyProgressSection(
val viewModel: ProgressViewModel = hiltViewModel(activity)
val weeklyActivityStats by viewModel.weeklyActivityStats.collectAsState()
Column(modifier = modifier) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(text = stringResource(R.string.label_weekly_progress), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
TextButton(onClick = { navController.navigate(NavigationRoutes.STATS_VOCABULARY_HEATMAP) }) {
Text(stringResource(R.string.label_see_history))
}
}
Spacer(modifier = Modifier.height(8.dp))
LabeledSection(
title = stringResource(R.string.label_weekly_progress),
modifier = modifier,
actionLabel = stringResource(R.string.label_see_history),
onActionClick = { navController.navigate(NavigationRoutes.STATS_VOCABULARY_HEATMAP) }
) {
AppCard(
modifier = Modifier.fillMaxWidth(),
onClick = { navController.navigate(NavigationRoutes.STATS_VOCABULARY_HEATMAP) }
@@ -401,12 +399,16 @@ fun BottomStatsSection(
val learnedWords by viewModel.totalWordsCompleted.collectAsState()
Row(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.height(IntrinsicSize.Min),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
// Total Words
AppCard(
modifier = Modifier.weight(1f),
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
onClick = { navController.navigate(Screen.Library.route) }
) {
Column(modifier = Modifier.padding(20.dp)) {
@@ -419,11 +421,13 @@ fun BottomStatsSection(
// Learned
AppCard(
modifier = Modifier.weight(1f),
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
onClick = { navController.navigate(NavigationRoutes.STATS_LANGUAGE_PROGRESS) }
) {
Column(modifier = Modifier.padding(20.dp)) {
Text(text = "LEARNED", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f))
Text(text = stringResource(R.string.label_learned).uppercase(), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f))
Spacer(modifier = Modifier.height(8.dp))
Text(text = learnedWords.toString(), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(8.dp))

View File

@@ -1,5 +1,3 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.view.library
import androidx.compose.animation.Crossfade
@@ -35,11 +33,13 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.AddCircleOutline
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.LocalMall
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.Star
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
@@ -60,6 +60,7 @@ import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@@ -72,6 +73,8 @@ import eu.gaudian.translator.R
import eu.gaudian.translator.model.Language
import eu.gaudian.translator.model.VocabularyCategory
import eu.gaudian.translator.model.VocabularyItem
import eu.gaudian.translator.model.VocabularyStage
import eu.gaudian.translator.view.composable.AppCard
import eu.gaudian.translator.view.composable.AppIcons
import eu.gaudian.translator.view.composable.insertBreakOpportunities
@@ -89,7 +92,7 @@ fun LibraryTopBar(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Library",
text = stringResource(R.string.label_library),
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
@@ -102,7 +105,7 @@ fun LibraryTopBar(
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = "Add",
contentDescription = stringResource(R.string.cd_add),
tint = MaterialTheme.colorScheme.primary
)
}
@@ -120,6 +123,7 @@ fun SelectionTopBar(
onDeleteClick: () -> Unit,
onMoveToCategoryClick: () -> Unit,
onMoveToStageClick: () -> Unit,
onExportClick: () -> Unit,
isRemoveEnabled: Boolean,
onRemoveFromCategoryClick: () -> Unit,
modifier: Modifier = Modifier
@@ -128,22 +132,29 @@ fun SelectionTopBar(
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onCloseClick) {
Icon(imageVector = AppIcons.Close, contentDescription = stringResource(R.string.label_close_selection_mode))
}
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.d_selected, selectionCount),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
// 1. Close Button
IconButton(onClick = onCloseClick) {
Icon(
imageVector = AppIcons.Close,
contentDescription = stringResource(R.string.label_close_selection_mode)
)
}
Row {
// 2. Title Text (Gets weight to prevent pushing icons off-screen)
Text(
text = stringResource(R.string.d_selected, selectionCount),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
// 3. Action Icons Group
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onSelectAllClick) {
Icon(
imageVector = AppIcons.SelectAll,
@@ -161,6 +172,14 @@ fun SelectionTopBar(
expanded = showOverflowMenu,
onDismissRequest = { showOverflowMenu = false }
) {
DropdownMenuItem(
text = { Text("Export Selected") },
onClick = {
onExportClick()
showOverflowMenu = false
},
leadingIcon = { Icon(AppIcons.Share, contentDescription = null) }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.move_to_category)) },
onClick = {
@@ -214,7 +233,7 @@ fun SearchBar(
) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = "Search",
contentDescription = stringResource(R.string.cd_search),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(12.dp))
@@ -232,7 +251,7 @@ fun SearchBar(
Box(contentAlignment = Alignment.CenterStart) {
if (searchQuery.isEmpty()) {
Text(
text = "Search cards or topics...",
text = stringResource(R.string.label_search_cards),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
style = MaterialTheme.typography.bodyLarge
)
@@ -245,7 +264,7 @@ fun SearchBar(
IconButton(onClick = onFilterClick) {
Icon(
imageVector = Icons.Default.Tune,
contentDescription = "Filter options",
contentDescription = stringResource(R.string.cd_filter_options),
tint = MaterialTheme.colorScheme.primary
)
}
@@ -279,7 +298,7 @@ fun SegmentedControl(
contentAlignment = Alignment.Center
) {
Text(
text = "All Cards",
text = stringResource(R.string.label_all_cards),
color = if (!isCategoriesView) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.labelLarge
@@ -296,7 +315,7 @@ fun SegmentedControl(
contentAlignment = Alignment.Center
) {
Text(
text = "Categories",
text = stringResource(R.string.label_categories),
color = if (isCategoriesView) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.labelLarge
@@ -313,10 +332,12 @@ fun AllCardsView(
vocabularyItems: List<VocabularyItem>,
allLanguages: List<Language>,
selection: Set<Long>,
stageMapping: Map<Int, VocabularyStage> = emptyMap(),
onItemClick: (VocabularyItem) -> Unit,
onItemLongClick: (VocabularyItem) -> Unit,
onDeleteClick: (VocabularyItem) -> Unit,
listState: LazyListState,
onAddClick: (() -> Unit)? = null,
modifier: Modifier = Modifier
) {
if (vocabularyItems.isEmpty()) {
@@ -339,11 +360,26 @@ fun AllCardsView(
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
if (onAddClick != null) {
Spacer(modifier = Modifier.height(24.dp))
androidx.compose.material3.Button(
onClick = onAddClick,
modifier = Modifier.fillMaxWidth(0.6f)
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = stringResource(R.string.label_add_vocabulary))
}
}
}
} else {
LazyColumn(
state = listState,
verticalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 100.dp)
) {
@@ -352,10 +388,12 @@ fun AllCardsView(
key = { it.id }
) { item ->
val isSelected = selection.contains(item.id.toLong())
val stage = stageMapping[item.id] ?: VocabularyStage.NEW
VocabularyCard(
item = item,
allLanguages = allLanguages,
isSelected = isSelected,
stage = stage,
onItemClick = { onItemClick(item) },
onItemLongClick = { onItemLongClick(item) },
onDeleteClick = { onDeleteClick(item) }
@@ -365,14 +403,12 @@ fun AllCardsView(
}
}
/**
* Individual vocabulary card component
*/
@Composable
fun VocabularyCard(
item: VocabularyItem,
allLanguages: List<Language>,
isSelected: Boolean,
stage: VocabularyStage = VocabularyStage.NEW,
onItemClick: () -> Unit,
onItemLongClick: () -> Unit,
onDeleteClick: () -> Unit,
@@ -385,14 +421,15 @@ fun VocabularyCard(
Card(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clip(RoundedCornerShape(12.dp)) // Slightly rounder for a modern look
.combinedClickable(
onClick = onItemClick,
onLongClick = onItemLongClick
),
colors = CardDefaults.cardColors(
containerColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainer,
contentColor = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.surfaceContainer
containerColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
// Fixed the contentColor bug here:
contentColor = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
),
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null
) {
@@ -403,72 +440,174 @@ fun VocabularyCard(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 12.dp) // Ensures text doesn't bleed into the trailing icon
) {
// Top row: First word + Language Pill
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = insertBreakOpportunities(item.wordFirst),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
color = MaterialTheme.colorScheme.onSurface,
// This modifier allows the text to wrap without squishing the pill
modifier = Modifier.weight(1f, fill = false)
)
Spacer(modifier = Modifier.width(8.dp))
Surface(
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f),
shape = RoundedCornerShape(4.dp)
) {
Text(
text = langFirst,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
LanguagePill(
text = langFirst,
backgroundColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f),
textColor = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(4.dp))
Spacer(modifier = Modifier.height(6.dp)) // Slightly more breathing room
// Bottom row: Second word + Language Pill
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = insertBreakOpportunities(item.wordSecond),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.onSurfaceVariant,
// Applied to the second text as well for consistency
modifier = Modifier.weight(1f, fill = false)
)
Spacer(modifier = Modifier.width(8.dp))
Surface(
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f),
shape = RoundedCornerShape(8.dp)
) {
Text(
text = langSecond,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)
)
}
LanguagePill(
text = langSecond,
backgroundColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f),
textColor = MaterialTheme.colorScheme.primary
)
}
}
if (isSelected) {
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = "Selected",
contentDescription = stringResource(R.string.cd_selected),
tint = MaterialTheme.colorScheme.primary
)
} else {
IconButton(onClick = { /* Options menu could go here */ }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = "Options",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Stage indicator showing the vocabulary item's learning stage
StageIndicator(stage = stage)
}
}
}
}
@Composable
fun StageIndicator(
stage: VocabularyStage,
modifier: Modifier = Modifier
) {
// Convert VocabularyStage to a step number (0-6)
val step = when (stage) {
VocabularyStage.NEW -> 0
VocabularyStage.STAGE_1 -> 1
VocabularyStage.STAGE_2 -> 2
VocabularyStage.STAGE_3 -> 3
VocabularyStage.STAGE_4 -> 4
VocabularyStage.STAGE_5 -> 5
VocabularyStage.LEARNED -> 6
}
// 1. Calculate how full the ring should be (0.0 to 1.0)
val maxSteps = 6f
val progress = step / maxSteps
// 2. Determine the ring color based on the stage
val indicatorColor = when (stage) {
VocabularyStage.NEW -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f)
VocabularyStage.STAGE_1, VocabularyStage.STAGE_2 -> Color(0xFFE57373) // Soft Red
VocabularyStage.STAGE_3 -> Color(0xFFFFB74D) // Soft Orange
VocabularyStage.STAGE_4 -> Color(0xFFFFD54F) // Soft Yellow
VocabularyStage.STAGE_5 -> Color(0xFFAED581) // Light Green
VocabularyStage.LEARNED -> Color(0xFF81C784) // Solid Green
}
Box(
contentAlignment = Alignment.Center,
modifier = modifier.size(36.dp) // Keeps it neatly sized within the row
) {
// The background track (empty ring)
CircularProgressIndicator(
progress = { 1f },
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f),
strokeWidth = 3.dp,
modifier = Modifier.fillMaxSize()
)
// The colored progress ring
if (stage != VocabularyStage.NEW) {
CircularProgressIndicator(
progress = { progress },
color = indicatorColor,
strokeWidth = 3.dp,
strokeCap = StrokeCap.Round, // Gives the progress bar nice rounded ends
modifier = Modifier.fillMaxSize()
)
}
// The center content (Number or Icon)
when (stage) {
VocabularyStage.NEW -> {
// An empty dot or small icon to denote it's untouched
Icon(
imageVector = Icons.Rounded.Star, // Or any generic 'new' icon
contentDescription = "New Word",
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f),
modifier = Modifier.size(16.dp)
)
}
VocabularyStage.LEARNED -> {
// A checkmark for mastery
Icon(
imageVector = Icons.Rounded.Check,
contentDescription = "Learned",
tint = indicatorColor,
modifier = Modifier.size(20.dp)
)
}
else -> {
// Display the actual level number (1 through 5)
Text(
text = step.toString(),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
}
}
}
}
// Extracted for consistency and cleaner code
@Composable
private fun LanguagePill(
text: String,
backgroundColor: Color,
textColor: Color
) {
if (text.isNotEmpty()) {
Surface(
color = backgroundColor,
shape = RoundedCornerShape(6.dp) // Consistent corner rounding for all pills
) {
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
color = textColor,
// Guaranteed to never wrap awkwardly
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
}
}
/**
* Grid view of categories
*/
@@ -508,13 +647,11 @@ fun CategoryCard(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
AppCard(
modifier = modifier
.fillMaxWidth()
.height(140.dp)
.clickable(onClick = onClick),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Column(
modifier = Modifier
@@ -601,7 +738,7 @@ fun ExploreMoreCard(
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Explore more categories",
text = stringResource(R.string.text_explore_more_categories),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -653,6 +790,7 @@ fun SelectionTopBarPreview() {
onDeleteClick = {},
onMoveToCategoryClick = {},
onMoveToStageClick = {},
onExportClick = {},
isRemoveEnabled = true,
onRemoveFromCategoryClick = {}
)
@@ -682,6 +820,7 @@ fun SegmentedControlPreview() {
}
}
@Suppress("HardCodedStringLiteral")
@Preview(showBackground = true)
@Composable
fun VocabularyCardPreview() {
@@ -700,6 +839,7 @@ fun VocabularyCardPreview() {
),
allLanguages = emptyList(),
isSelected = false,
stage = VocabularyStage.NEW,
onItemClick = {},
onItemLongClick = {},
onDeleteClick = {}
@@ -707,6 +847,155 @@ fun VocabularyCardPreview() {
}
}
@Suppress("HardCodedStringLiteral")
@Preview(showBackground = true)
@Composable
fun VocabularyCardStage1Preview() {
MaterialTheme {
VocabularyCard(
item = VocabularyItem(
id = 2,
wordFirst = "Goodbye",
wordSecond = "Adiós",
languageFirstId = 1,
languageSecondId = 2,
createdAt = null,
features = null,
zipfFrequencyFirst = null,
zipfFrequencySecond = null
),
allLanguages = emptyList(),
isSelected = false,
stage = VocabularyStage.STAGE_1,
onItemClick = {},
onItemLongClick = {},
onDeleteClick = {}
)
}
}
@Suppress("HardCodedStringLiteral")
@Preview(showBackground = true)
@Composable
fun VocabularyCardStage3Preview() {
MaterialTheme {
VocabularyCard(
item = VocabularyItem(
id = 3,
wordFirst = "Thank you",
wordSecond = "Gracias",
languageFirstId = 1,
languageSecondId = 2,
createdAt = null,
features = null,
zipfFrequencyFirst = null,
zipfFrequencySecond = null
),
allLanguages = emptyList(),
isSelected = false,
stage = VocabularyStage.STAGE_3,
onItemClick = {},
onItemLongClick = {},
onDeleteClick = {}
)
}
}
@Suppress("HardCodedStringLiteral")
@Preview(showBackground = true)
@Composable
fun VocabularyCardStage5Preview() {
MaterialTheme {
VocabularyCard(
item = VocabularyItem(
id = 4,
wordFirst = "Please",
wordSecond = "Por favor",
languageFirstId = 1,
languageSecondId = 2,
createdAt = null,
features = null,
zipfFrequencyFirst = null,
zipfFrequencySecond = null
),
allLanguages = emptyList(),
isSelected = false,
stage = VocabularyStage.STAGE_5,
onItemClick = {},
onItemLongClick = {},
onDeleteClick = {}
)
}
}
@Suppress("HardCodedStringLiteral")
@Preview(showBackground = true)
@Composable
fun VocabularyCardLearnedPreview() {
MaterialTheme {
VocabularyCard(
item = VocabularyItem(
id = 5,
wordFirst = "Yes",
wordSecond = "",
languageFirstId = 1,
languageSecondId = 2,
createdAt = null,
features = null,
zipfFrequencyFirst = null,
zipfFrequencySecond = null
),
allLanguages = emptyList(),
isSelected = false,
stage = VocabularyStage.LEARNED,
onItemClick = {},
onItemLongClick = {},
onDeleteClick = {}
)
}
}
@Preview(showBackground = true)
@Composable
fun StageIndicatorNewPreview() {
MaterialTheme {
StageIndicator(stage = VocabularyStage.NEW)
}
}
@Preview(showBackground = true)
@Composable
fun StageIndicatorStage1Preview() {
MaterialTheme {
StageIndicator(stage = VocabularyStage.STAGE_1)
}
}
@Preview(showBackground = true)
@Composable
fun StageIndicatorStage3Preview() {
MaterialTheme {
StageIndicator(stage = VocabularyStage.STAGE_3)
}
}
@Preview(showBackground = true)
@Composable
fun StageIndicatorStage5Preview() {
MaterialTheme {
StageIndicator(stage = VocabularyStage.STAGE_5)
}
}
@Preview(showBackground = true)
@Composable
fun StageIndicatorLearnedPreview() {
MaterialTheme {
StageIndicator(stage = VocabularyStage.LEARNED)
}
}
@Suppress("HardCodedStringLiteral")
@Preview(showBackground = true)
@Composable
fun CategoryCardPreview() {

View File

@@ -73,10 +73,12 @@ import eu.gaudian.translator.view.composable.MultipleLanguageDropdown
import eu.gaudian.translator.view.dialogs.AddCategoryDialog
import eu.gaudian.translator.view.dialogs.CategorySelectionDialog
import eu.gaudian.translator.view.dialogs.StageSelectionDialog
import eu.gaudian.translator.viewmodel.ExportImportViewModel
import eu.gaudian.translator.viewmodel.LanguageConfigViewModel
import eu.gaudian.translator.viewmodel.LanguageViewModel
import eu.gaudian.translator.viewmodel.VocabularyViewModel
import eu.gaudian.translator.viewmodel.VocabularyViewModel.SortOrder
import eu.gaudian.translator.viewmodel.toStringResource
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
@@ -97,11 +99,14 @@ fun LibraryScreen(
modifier: Modifier = Modifier
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
val activity = LocalContext.current.findActivity()
val lazyListState = rememberLazyListState()
val vocabularyViewModel: VocabularyViewModel = hiltViewModel(viewModelStoreOwner = activity)
val exportImportViewModel: ExportImportViewModel = hiltViewModel(viewModelStoreOwner = activity)
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
var filterState by rememberSaveable { mutableStateOf(LibraryFilterState()) }
var showFilterSheet by remember { mutableStateOf(false) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
@@ -119,6 +124,7 @@ fun LibraryScreen(
val allLanguages by languageViewModel.allLanguages.collectAsStateWithLifecycle(initialValue = emptyList())
val languagesPresent by vocabularyViewModel.languagesPresent.collectAsStateWithLifecycle(initialValue = emptySet())
val categories by vocabularyViewModel.categories.collectAsStateWithLifecycle()
val exportState by exportImportViewModel.exportState.collectAsStateWithLifecycle()
val vocabularyItemsFlow = remember(filterState) {
vocabularyViewModel.filterVocabularyItems(
@@ -133,6 +139,17 @@ fun LibraryScreen(
}
val vocabularyItems by vocabularyItemsFlow.collectAsStateWithLifecycle(initialValue = emptyList())
val stageMapping by vocabularyViewModel.stageMapping.collectAsStateWithLifecycle(initialValue = emptyMap())
// Handle export state
LaunchedEffect(exportState) {
if (exportState is eu.gaudian.translator.viewmodel.ExportState.Success) {
exportImportViewModel.createShareIntent()?.let { intent ->
context.startActivity(intent)
}
exportImportViewModel.resetExportState()
}
}
var isHeaderVisible by remember { mutableStateOf(true) }
var previousIndex by remember { mutableIntStateOf(0) }
@@ -195,6 +212,11 @@ fun LibraryScreen(
},
onMoveToCategoryClick = { showCategoryDialog = true },
onMoveToStageClick = { showStageDialog = true },
onExportClick = {
val selectedIds = selection.map { it.toInt() }
exportImportViewModel.exportItemList(selectedIds)
selection = emptySet()
},
isRemoveEnabled = false,
onRemoveFromCategoryClick = {}
)
@@ -242,7 +264,9 @@ fun LibraryScreen(
vocabularyItems = vocabularyItems,
allLanguages = allLanguages,
selection = selection,
stageMapping = stageMapping,
listState = lazyListState,
onAddClick = { navController.navigate(NavigationRoutes.NEW_WORD) },
onItemClick = { item ->
if (isInSelectionMode) {
selection = if (selection.contains(item.id.toLong())) {
@@ -465,8 +489,7 @@ fun FilterBottomSheetContent(
selected = sortOrder == order,
onClick = { sortOrder = order },
label = {
Text(order.name.replace('_', ' ').lowercase()
.replaceFirstChar { it.titlecase() })
Text(stringResource(order.toStringResource()))
}
)
}

View File

@@ -1,4 +1,4 @@
package eu.gaudian.translator.view.exercises
package eu.gaudian.translator.view.new_ecercises
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing

View File

@@ -1,4 +1,4 @@
package eu.gaudian.translator.view.exercises
package eu.gaudian.translator.view.new_ecercises
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column

View File

@@ -1,4 +1,4 @@
package eu.gaudian.translator.view.exercises
package eu.gaudian.translator.view.new_ecercises
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier

View File

@@ -1,4 +1,4 @@
package eu.gaudian.translator.view.exercises
package eu.gaudian.translator.view.new_ecercises
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
@@ -57,7 +57,7 @@ import eu.gaudian.translator.utils.findActivity
import eu.gaudian.translator.view.composable.AppButton
import eu.gaudian.translator.view.composable.AppIcons
import eu.gaudian.translator.view.composable.ComponentDefaults
import eu.gaudian.translator.view.vocabulary.ExerciseProgressIndicator
import eu.gaudian.translator.view.exercises.ExerciseProgressIndicator
import eu.gaudian.translator.viewmodel.AnswerResult
import eu.gaudian.translator.viewmodel.ExerciseSessionState
import eu.gaudian.translator.viewmodel.ExerciseViewModel

View File

@@ -1,4 +1,4 @@
package eu.gaudian.translator.view.exercises
package eu.gaudian.translator.view.new_ecercises
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth

View File

@@ -1,6 +1,6 @@
@file:Suppress("AssignedValueIsNeverRead")
package eu.gaudian.translator.view.exercises
package eu.gaudian.translator.view.new_ecercises
import android.annotation.SuppressLint
import androidx.compose.foundation.background

View File

@@ -1,6 +1,6 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.view.exercises
package eu.gaudian.translator.view.new_ecercises
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement

View File

@@ -1,4 +1,4 @@
package eu.gaudian.translator.view.exercises
package eu.gaudian.translator.view.new_ecercises
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background

View File

@@ -1,4 +1,4 @@
package eu.gaudian.translator.view.exercises
package eu.gaudian.translator.view.new_ecercises
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
@@ -35,6 +35,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
@@ -56,7 +57,6 @@ 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.TagCategory
import eu.gaudian.translator.model.VocabularyCategory
import eu.gaudian.translator.model.VocabularyStage
import eu.gaudian.translator.utils.findActivity
@@ -76,17 +76,40 @@ import kotlinx.coroutines.launch
@Composable
fun StartExerciseScreen(
navController: NavHostController,
preselectedCategoryId: Int? = null,
dueTodayOnly: Boolean = false,
modifier: Modifier = Modifier
) {
val activity = androidx.compose.ui.platform.LocalContext.current.findActivity()
val vocabularyViewModel: VocabularyViewModel = hiltViewModel(viewModelStoreOwner = activity)
val categoryViewModel: CategoryViewModel = hiltViewModel(viewModelStoreOwner = activity)
val exerciseViewModel: VocabularyExerciseViewModel = hiltViewModel(viewModelStoreOwner = activity)
// Initialize exercise config with dueTodayOnly if specified
androidx.compose.runtime.LaunchedEffect(dueTodayOnly) {
if (dueTodayOnly) {
exerciseViewModel.updatePendingExerciseConfig(
exerciseViewModel.pendingExerciseConfig.value.copy(dueTodayOnly = true)
)
}
}
val exerciseConfig by exerciseViewModel.pendingExerciseConfig.collectAsState()
val allCategories by categoryViewModel.categories.collectAsState(initial = emptyList())
var selectedLanguagePairs by remember { mutableStateOf<List<Pair<Language, Language>>>(emptyList()) }
var selectedCategories by remember { mutableStateOf<List<VocabularyCategory>>(emptyList()) }
var selectedStages by remember { mutableStateOf<List<VocabularyStage>>(emptyList()) }
// Initialize preselected category
LaunchedEffect(allCategories, preselectedCategoryId) {
if (preselectedCategoryId != null) {
val category = allCategories.find { it.id == preselectedCategoryId }
if (category != null && category !in selectedCategories) {
selectedCategories = listOf(category)
}
}
}
var selectedOriginLanguage by remember { mutableStateOf<Language?>(null) }
var selectedTargetLanguage by remember { mutableStateOf<Language?>(null) }
val isDirectionPreferenceSet = selectedOriginLanguage != null || selectedTargetLanguage != null
@@ -143,6 +166,13 @@ fun StartExerciseScreen(
.widthIn(max = 700.dp) // Keeps it from over-stretching on tablets
.fillMaxSize()
) {
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
val allLanguages by languageViewModel.allLanguages.collectAsState(initial = emptyList())
val availableLanguages = remember(availableLanguagesFromItems, allLanguages) {
allLanguages.filter { it.nameResId in availableLanguagesFromItems }
}
TopBarSection(
onBackClick = { navController.popBackStack() },
shuffleCards = exerciseConfig.shuffleCards,
@@ -152,6 +182,36 @@ fun StartExerciseScreen(
shuffleLanguagesEnabled = !isDirectionPreferenceSet,
trainingMode = exerciseConfig.trainingMode,
onTrainingModeChanged = { updateConfig(exerciseConfig.copy(trainingMode = it)) },
selectedOriginLanguage = selectedOriginLanguage,
selectedTargetLanguage = selectedTargetLanguage,
languageSelectionEnabled = true,
availableLanguages = availableLanguages,
onOriginLanguageSelected = { language ->
if (language?.nameResId == selectedOriginLanguage?.nameResId) {
selectedOriginLanguage = null
updateConfig(exerciseConfig.copy(originLanguageId = null))
} else {
selectedOriginLanguage = language
if (selectedTargetLanguage?.nameResId == language?.nameResId) {
selectedTargetLanguage = null
updateConfig(exerciseConfig.copy(targetLanguageId = null))
}
updateConfig(exerciseConfig.copy(originLanguageId = language?.nameResId))
}
},
onTargetLanguageSelected = { language ->
if (language?.nameResId == selectedTargetLanguage?.nameResId) {
selectedTargetLanguage = null
updateConfig(exerciseConfig.copy(targetLanguageId = null))
} else {
selectedTargetLanguage = language
if (selectedOriginLanguage?.nameResId == language?.nameResId) {
selectedOriginLanguage = null
updateConfig(exerciseConfig.copy(originLanguageId = null))
}
updateConfig(exerciseConfig.copy(targetLanguageId = language?.nameResId))
}
}
)
LazyColumn(
@@ -176,36 +236,7 @@ fun StartExerciseScreen(
updateConfig(exerciseConfig.copy(originLanguageId = null, targetLanguageId = null))
}
},
onOriginLanguageSelected = { language ->
if (language?.nameResId == selectedOriginLanguage?.nameResId) {
selectedOriginLanguage = null
updateConfig(exerciseConfig.copy(originLanguageId = null))
} else {
selectedOriginLanguage = language
if (selectedTargetLanguage?.nameResId == language?.nameResId) {
selectedTargetLanguage = null
updateConfig(exerciseConfig.copy(targetLanguageId = null))
}
updateConfig(exerciseConfig.copy(originLanguageId = language?.nameResId))
}
},
onTargetLanguageSelected = { language ->
if (language?.nameResId == selectedTargetLanguage?.nameResId) {
selectedTargetLanguage = null
updateConfig(exerciseConfig.copy(targetLanguageId = null))
} else {
selectedTargetLanguage = language
if (selectedOriginLanguage?.nameResId == language?.nameResId) {
selectedOriginLanguage = null
updateConfig(exerciseConfig.copy(originLanguageId = null))
}
updateConfig(exerciseConfig.copy(targetLanguageId = language?.nameResId))
}
},
languageSelectionEnabled = true,
selectedPairsCount = selectedLanguagePairs.size,
selectedOriginLanguage = selectedOriginLanguage,
selectedTargetLanguage = selectedTargetLanguage
selectedPairsCount = selectedLanguagePairs.size
)
}
item {
@@ -284,7 +315,13 @@ fun TopBarSection(
onShuffleLanguagesChanged: (Boolean) -> Unit,
shuffleLanguagesEnabled: Boolean,
trainingMode: Boolean,
onTrainingModeChanged: (Boolean) -> Unit
onTrainingModeChanged: (Boolean) -> Unit,
selectedOriginLanguage: Language?,
selectedTargetLanguage: Language?,
languageSelectionEnabled: Boolean,
availableLanguages: List<Language>,
onOriginLanguageSelected: (Language?) -> Unit,
onTargetLanguageSelected: (Language?) -> Unit
) {
var showSettings by remember { mutableStateOf(false) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
@@ -343,6 +380,12 @@ fun TopBarSection(
shuffleLanguagesEnabled = shuffleLanguagesEnabled,
trainingMode = trainingMode,
onTrainingModeChanged = onTrainingModeChanged,
selectedOriginLanguage = selectedOriginLanguage,
selectedTargetLanguage = selectedTargetLanguage,
languageSelectionEnabled = languageSelectionEnabled,
availableLanguages = availableLanguages,
onOriginLanguageSelected = onOriginLanguageSelected,
onTargetLanguageSelected = onTargetLanguageSelected,
onDismiss = {
scope.launch { sheetState.hide() }.invokeOnCompletion {
if (!sheetState.isVisible) {
@@ -388,12 +431,7 @@ fun LanguagePairSection(
selectedPairs: List<Pair<Language, Language>>,
availableLanguageIds: Set<Int>,
onPairsChanged: (List<Pair<Language, Language>>) -> Unit,
onOriginLanguageSelected: (Language?) -> Unit,
onTargetLanguageSelected: (Language?) -> Unit,
languageSelectionEnabled: Boolean,
selectedPairsCount: Int,
selectedOriginLanguage: Language?,
selectedTargetLanguage: Language?
selectedPairsCount: Int
) {
val activity = androidx.compose.ui.platform.LocalContext.current.findActivity()
val vocabularyViewModel: VocabularyViewModel = hiltViewModel(viewModelStoreOwner = activity)
@@ -428,8 +466,17 @@ fun LanguagePairSection(
}
}
var isExpanded by remember { mutableStateOf(false) }
val displayedPairs = if (isExpanded) availablePairs else availablePairs.take(3)
Column {
SectionHeader(title = stringResource(R.string.language_pair))
SectionHeader(
title = stringResource(R.string.language_pair),
actionText = if (availablePairs.size > 3) {
if (isExpanded) stringResource(R.string.label_show_less) else stringResource(R.string.label_show_more)
} else null,
onActionClick = { isExpanded = !isExpanded }
)
if (availablePairs.isEmpty()) {
Text(
@@ -442,7 +489,7 @@ fun LanguagePairSection(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
availablePairs.forEach { pair ->
displayedPairs.forEach { pair ->
val isSelected = selectedPairs.contains(pair)
LanguageChip(
text = "${pair.first.name}${pair.second.name}",
@@ -460,74 +507,6 @@ fun LanguagePairSection(
}
}
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.label_language_direction),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Text(
text = stringResource(R.string.text_language_direction_explanation),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (!languageSelectionEnabled && selectedPairsCount > 0) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.text_language_direction_disabled_with_pairs),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(12.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.label_origin_language),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 8.dp)
)
eu.gaudian.translator.view.composable.SingleLanguageDropDown(
modifier = Modifier.fillMaxWidth(),
languageViewModel = languageViewModel,
selectedLanguage = selectedOriginLanguage,
onLanguageSelected = { language ->
if (selectedTargetLanguage?.nameResId == language.nameResId) return@SingleLanguageDropDown
onOriginLanguageSelected(language)
},
showNoneOption = true,
onNoneSelected = { onOriginLanguageSelected(null) },
alternateLanguages = availableLanguages,
restrictToAlternateLanguages = true,
enabled = languageSelectionEnabled
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.label_target_language),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 8.dp)
)
eu.gaudian.translator.view.composable.SingleLanguageDropDown(
modifier = Modifier.fillMaxWidth(),
languageViewModel = languageViewModel,
selectedLanguage = selectedTargetLanguage,
onLanguageSelected = { language ->
if (selectedOriginLanguage?.nameResId == language.nameResId) return@SingleLanguageDropDown
onTargetLanguageSelected(language)
},
showNoneOption = true,
onNoneSelected = { onTargetLanguageSelected(null) },
alternateLanguages = availableLanguages,
restrictToAlternateLanguages = true,
enabled = languageSelectionEnabled
)
}
}
}
}
@@ -576,10 +555,25 @@ fun CategoriesSection(
val categories by categoryViewModel.categories.collectAsState(initial = emptyList())
Column {
SectionHeader(title = stringResource(R.string.label_categories))
val tagCategories = categories
var isExpanded by remember { mutableStateOf(false) }
val displayedCategories = if (isExpanded) tagCategories else tagCategories.take(3)
val tagCategories = categories.filterIsInstance<TagCategory>()
if (tagCategories.size > 15) {
SectionHeader(
title = stringResource(R.string.label_categories),
actionText = if (tagCategories.size > 3) {
if (isExpanded) stringResource(R.string.label_show_less) else stringResource(R.string.label_show_more)
} else null,
onActionClick = { isExpanded = !isExpanded }
)
if (tagCategories.isEmpty()) {
Text(
text = stringResource(R.string.no_vocabulary_items_found_perhaps_try_changing_the_filters),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else if (tagCategories.size > 15) {
CategoryDropdown(
onCategorySelected = { selections ->
onCategoriesChanged(selections.filterNotNull())
@@ -596,7 +590,7 @@ fun CategoriesSection(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
tagCategories.forEach { category ->
displayedCategories.forEach { category ->
val isSelected = selectedCategories.contains(category)
Surface(
shape = RoundedCornerShape(20.dp),
@@ -743,9 +737,9 @@ fun NumberOfCardsSection(
availableQuickSelections.forEach { value ->
AppOutlinedButton(
onClick = { onAmountChanged(value) },
modifier = Modifier.weight(1f)
modifier = Modifier.weight(1f).padding(0.dp)
) {
Text(value.toString())
Text(text = value.toString(), softWrap = false)
}
}
}
@@ -779,7 +773,7 @@ fun QuestionTypesSection(
Spacer(modifier = Modifier.height(12.dp))
QuestionTypeCard(
title = stringResource(R.string.label_multiple_choice_exercise),
subtitle = stringResource(R.string.label_choose_exercise_types),
subtitle = stringResource(R.string.label_multiple_choice_desc),
icon = AppIcons.CheckList,
isSelected = selectedTypes.contains(VocabularyExerciseType.MULTIPLE_CHOICE),
onClick = { onTypeSelected(VocabularyExerciseType.MULTIPLE_CHOICE) }
@@ -879,8 +873,17 @@ private fun StartExerciseSettingsBottomSheet(
shuffleLanguagesEnabled: Boolean,
trainingMode: Boolean,
onTrainingModeChanged: (Boolean) -> Unit,
selectedOriginLanguage: Language?,
selectedTargetLanguage: Language?,
languageSelectionEnabled: Boolean,
availableLanguages: List<Language>,
onOriginLanguageSelected: (Language?) -> Unit,
onTargetLanguageSelected: (Language?) -> Unit,
onDismiss: () -> Unit
) {
val activity = androidx.compose.ui.platform.LocalContext.current.findActivity()
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState
@@ -889,7 +892,7 @@ private fun StartExerciseSettingsBottomSheet(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = stringResource(R.string.options),
@@ -927,6 +930,73 @@ private fun StartExerciseSettingsBottomSheet(
checked = trainingMode,
onCheckedChange = onTrainingModeChanged
)
// Language Direction Section
Text(
text = stringResource(R.string.label_language_direction),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Text(
text = stringResource(R.string.text_language_direction_explanation),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (!languageSelectionEnabled) {
Text(
text = stringResource(R.string.text_language_direction_disabled_with_pairs),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(12.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.label_origin_language),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 8.dp)
)
eu.gaudian.translator.view.composable.SingleLanguageDropDown(
modifier = Modifier.fillMaxWidth(),
languageViewModel = languageViewModel,
selectedLanguage = selectedOriginLanguage,
onLanguageSelected = { language ->
if (selectedTargetLanguage?.nameResId == language.nameResId) return@SingleLanguageDropDown
onOriginLanguageSelected(language)
},
showNoneOption = true,
onNoneSelected = { onOriginLanguageSelected(null) },
alternateLanguages = availableLanguages,
restrictToAlternateLanguages = true,
enabled = languageSelectionEnabled
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.label_target_language),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 8.dp)
)
eu.gaudian.translator.view.composable.SingleLanguageDropDown(
modifier = Modifier.fillMaxWidth(),
languageViewModel = languageViewModel,
selectedLanguage = selectedTargetLanguage,
onLanguageSelected = { language ->
if (selectedOriginLanguage?.nameResId == language.nameResId) return@SingleLanguageDropDown
onTargetLanguageSelected(language)
},
showNoneOption = true,
onNoneSelected = { onTargetLanguageSelected(null) },
alternateLanguages = availableLanguages,
restrictToAlternateLanguages = true,
enabled = languageSelectionEnabled
)
}
}
}
}
}
}

View File

@@ -1,6 +1,6 @@
@file:Suppress("AssignedValueIsNeverRead")
package eu.gaudian.translator.view.exercises
package eu.gaudian.translator.view.new_ecercises
import android.annotation.SuppressLint
import android.graphics.Bitmap

View File

@@ -1,4 +1,4 @@
package eu.gaudian.translator.view.exercises
package eu.gaudian.translator.view.new_ecercises
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box

View File

@@ -1,6 +1,6 @@
@file:Suppress("AssignedValueIsNeverRead")
package eu.gaudian.translator.view.exercises
package eu.gaudian.translator.view.new_ecercises
import android.annotation.SuppressLint
import android.widget.Toast

View File

@@ -136,7 +136,7 @@ fun AddModelScreen(navController: NavController, providerKey: String) {
AppTopAppBar(
title = providerName,
onNavigateBack = { navController.popBackStack() },
hintContent = HintDefinition.ADD_MODEL_SCAN.hint()
hint = HintDefinition.ADD_MODEL_SCAN.hint()
)
},
) { paddingValues ->

View File

@@ -117,7 +117,7 @@ fun ApiKeyScreen(navController: NavController) {
AppTopAppBar(
title = stringResource(R.string.label_ai_configuration),
onNavigateBack = { navController.popBackStack() },
hintContent = HintDefinition.API_KEY.hint()
hint = HintDefinition.API_KEY.hint()
)
}
) { paddingValues ->

View File

@@ -53,7 +53,7 @@ fun CustomVocabularyPromptScreen(
AppTopAppBar(
title = stringResource(R.string.text_vocabulary_prompt),
onNavigateBack = { navController.popBackStack() },
hintContent = null //TODO: Add hint
hint = null //TODO: Add hint
)
}

View File

@@ -64,7 +64,7 @@ fun DictionaryOptionsScreen(
AppTopAppBar(
title = stringResource(R.string.label_dictionary_options),
onNavigateBack = { navController.popBackStack() },
hintContent = HintDefinition.DICTIONARY_OPTIONS.hint()
hint = HintDefinition.DICTIONARY_OPTIONS.hint()
)
}
) { paddingValues ->

View File

@@ -62,7 +62,7 @@ fun TranslationSettingsScreen(
AppTopAppBar(
title = stringResource(R.string.label_translation_settings),
onNavigateBack = { navController.popBackStack() },
hintContent = null //TODO add hint
hint = null //TODO add hint
)
}
) { paddingValues ->

View File

@@ -79,7 +79,7 @@ fun VocabularyProgressOptionsScreen(
AppTopAppBar(
title = stringResource(R.string.label_vocabulary_settings),
onNavigateBack = { navController.popBackStack() },
hintContent = HintDefinition.VOCABULARY_PROGRESS.hint()
hint = HintDefinition.VOCABULARY_PROGRESS.hint()
)
}
) { paddingValues ->

View File

@@ -1,84 +1,163 @@
@file:Suppress("AssignedValueIsNeverRead")
@file:Suppress("AssignedValueIsNeverRead", "HardCodedStringLiteral")
package eu.gaudian.translator.view.settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Divider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
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.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavController
import eu.gaudian.translator.R
import eu.gaudian.translator.model.ConflictStrategy
import eu.gaudian.translator.model.Language
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.AppIcons
import eu.gaudian.translator.view.composable.AppScaffold
import eu.gaudian.translator.view.composable.AppTopAppBar
import eu.gaudian.translator.view.composable.CsvImportDialog
import eu.gaudian.translator.view.composable.PrimaryButton
import eu.gaudian.translator.view.composable.SecondaryButton
import eu.gaudian.translator.view.composable.SingleLanguageDropDown
import eu.gaudian.translator.viewmodel.CategoryViewModel
import eu.gaudian.translator.viewmodel.ExportImportViewModel
import eu.gaudian.translator.viewmodel.ExportState
import eu.gaudian.translator.viewmodel.ImportState
import eu.gaudian.translator.viewmodel.LanguageViewModel
import eu.gaudian.translator.viewmodel.VocabularyViewModel
import kotlinx.coroutines.launch
@Composable
fun VocabularyRepositoryOptionsScreen(
navController: NavController
) {
val activity = LocalContext.current.findActivity()
val vocabularyViewModel: VocabularyViewModel = hiltViewModel(viewModelStoreOwner = activity)
val exportImportViewModel: ExportImportViewModel = hiltViewModel(viewModelStoreOwner = activity)
val categoryViewModel: CategoryViewModel = hiltViewModel(viewModelStoreOwner = activity)
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
val statusMessageService = StatusMessageService
val context = LocalContext.current
val repositoryStateImportedFrom = stringResource(R.string.repository_state_imported_from)
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
// State management
val exportState by exportImportViewModel.exportState.collectAsState()
val importState by exportImportViewModel.importState.collectAsState()
val categories by categoryViewModel.categories.collectAsState()
// Dialog states
var showExportDialog by remember { mutableStateOf(false) }
var showImportDialog by remember { mutableStateOf(false) }
var showConflictStrategyDialog by remember { mutableStateOf(false) }
var pendingImportJson by remember { mutableStateOf<String?>(null) }
var selectedConflictStrategy by remember { mutableStateOf(ConflictStrategy.MERGE) }
// Export options
val selectedCategories = remember { mutableStateListOf<Int>() }
// Handle export/import state changes
LaunchedEffect(exportState) {
when (exportState) {
is ExportState.Success -> {
val shareIntent = exportImportViewModel.createShareIntent()
if (shareIntent != null) {
context.startActivity(shareIntent)
}
scope.launch {
snackbarHostState.showSnackbar("Export successful!")
}
exportImportViewModel.resetExportState()
}
is ExportState.Error -> {
scope.launch {
snackbarHostState.showSnackbar((exportState as ExportState.Error).message)
}
exportImportViewModel.resetExportState()
}
else -> {}
}
}
LaunchedEffect(importState) {
when (importState) {
is ImportState.Success -> {
val result = (importState as ImportState.Success).result
scope.launch {
snackbarHostState.showSnackbar(
"Imported: ${result.itemsImported}, Skipped: ${result.itemsSkipped}, Errors: ${result.errors.size}"
)
}
exportImportViewModel.resetImportState()
}
is ImportState.Error -> {
scope.launch {
snackbarHostState.showSnackbar((importState as ImportState.Error).message)
}
exportImportViewModel.resetImportState()
}
else -> {}
}
}
// File picker for import
val importFileLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
onResult = { uri ->
uri?.let {
context.contentResolver.openInputStream(it)?.use { inputStream ->
val jsonString = inputStream.bufferedReader().use { reader -> reader.readText() }
vocabularyViewModel.importVocabulary(jsonString)
statusMessageService.showInfoMessage(repositoryStateImportedFrom + " " +it.path)
pendingImportJson = jsonString
showConflictStrategyDialog = true
}
}
}
)
// CSV/Excel import state
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
val showTableImportDialog = remember { mutableStateOf(false) }
var parsedTable by remember { mutableStateOf<List<List<String>>>(emptyList()) }
var selectedColFirst by remember { mutableIntStateOf(0) }
@@ -90,7 +169,6 @@ fun VocabularyRepositoryOptionsScreen(
fun parseCsv(text: String): List<List<String>> {
if (text.isBlank()) return emptyList()
// Detect delimiter by highest occurrence among comma, semicolon, tab
val candidates = listOf(',', ';', '\t')
val sampleLine = text.lineSequence().firstOrNull { it.isNotBlank() } ?: return emptyList()
val delimiter = candidates.maxBy { c -> sampleLine.count { it == c } }
@@ -106,14 +184,13 @@ fun VocabularyRepositoryOptionsScreen(
'"' -> {
if (inQuotes && i + 1 < text.length && text[i + 1] == '"') {
current.append('"')
i++ // skip escaped quote
i++
} else {
inQuotes = !inQuotes
}
}
'\r' -> { /* ignore, handle on \n */ }
'\r' -> { /* ignore */ }
'\n' -> {
// end of line
val field = current.toString()
current = StringBuilder()
currentRow.add(if (inQuotes) field else field)
@@ -133,12 +210,10 @@ fun VocabularyRepositoryOptionsScreen(
}
i++
}
// flush last field/row if any
if (current.isNotEmpty() || currentRow.isNotEmpty()) {
currentRow.add(current.toString())
rows.add(currentRow.toList())
}
// Normalize: trim and drop trailing empty columns
return rows.map { row ->
row.map { it.trim().trim('"') }
}.filter { r -> r.any { it.isNotBlank() } }
@@ -193,8 +268,8 @@ fun VocabularyRepositoryOptionsScreen(
vocabularyViewModel.initializeSaveFileLauncher(exportFileLauncher)
}
AppScaffold(
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
topBar = {
AppTopAppBar(
title = stringResource(R.string.vocabulary_repository),
@@ -209,31 +284,95 @@ fun VocabularyRepositoryOptionsScreen(
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Export Section
item {
// Backup and Restore Section
AppCard {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Export Vocabulary",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
if (exportState is ExportState.Loading) {
CircularProgressIndicator(modifier = Modifier.width(24.dp).height(24.dp))
}
}
Text(
text = stringResource(R.string.label_backup_and_restore),
style = MaterialTheme.typography.titleMedium
text = "Export your vocabulary data to share or backup",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Divider(modifier = Modifier.padding(vertical = 8.dp))
PrimaryButton(
onClick = { vocabularyViewModel.saveRepositoryState() },
text = stringResource(R.string.export_vocabulary_data),
modifier = Modifier.fillMaxWidth()
onClick = { exportImportViewModel.exportFullRepository() },
text = "Export Complete Repository",
icon = AppIcons.Download,
modifier = Modifier.fillMaxWidth(),
enabled = exportState !is ExportState.Loading
)
SecondaryButton(
onClick = { importFileLauncher.launch(arrayOf("application/json")) },
text = stringResource(R.string.import_vocabulary_data),
modifier = Modifier.fillMaxWidth()
onClick = { showExportDialog = true },
text = "Export Selected Categories",
icon = AppIcons.Category,
modifier = Modifier.fillMaxWidth(),
enabled = exportState !is ExportState.Loading && categories.isNotEmpty()
)
}
}
}
// Import Section
item {
AppCard {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Import Vocabulary",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
if (importState is ImportState.Loading) {
CircularProgressIndicator(modifier = Modifier.width(24.dp).height(24.dp))
}
}
Text(
text = "Import vocabulary from JSON files. Duplicates will be handled based on your chosen strategy.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Divider(modifier = Modifier.padding(vertical = 8.dp))
PrimaryButton(
onClick = { importFileLauncher.launch(arrayOf("application/json", "text/plain")) },
text = "Import from File",
icon = AppIcons.Upload,
modifier = Modifier.fillMaxWidth(),
enabled = importState !is ImportState.Loading
)
SecondaryButton(
onClick = {
// Allow CSV and Excel mime types, but we only support CSV parsing in-app
@Suppress("HardCodedStringLiteral")
importTableLauncher.launch(
arrayOf(
"text/csv",
@@ -246,11 +385,43 @@ fun VocabularyRepositoryOptionsScreen(
)
},
text = stringResource(R.string.label_import_table_csv_excel),
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
enabled = importState !is ImportState.Loading
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Conflict Strategy:",
style = MaterialTheme.typography.bodySmall
)
TextButton(onClick = { showImportDialog = true }) {
Text(
text = when (selectedConflictStrategy) {
ConflictStrategy.MERGE -> "Merge (Recommended)"
ConflictStrategy.SKIP -> "Skip Duplicates"
ConflictStrategy.REPLACE -> "Replace Existing"
ConflictStrategy.RENAME -> "Keep Both"
},
style = MaterialTheme.typography.bodySmall
)
Icon(
imageVector = AppIcons.Settings,
contentDescription = "Change strategy",
modifier = Modifier.padding(start = 4.dp)
)
}
}
}
}
}
// Danger Zone
item {
AppCard {
Column(
@@ -263,7 +434,7 @@ fun VocabularyRepositoryOptionsScreen(
color = MaterialTheme.colorScheme.error
)
val showConfirm = androidx.compose.runtime.remember { mutableStateOf(false) }
val showConfirm = remember { mutableStateOf(false) }
AppButton(
onClick = { showConfirm.value = true },
@@ -304,124 +475,250 @@ fun VocabularyRepositoryOptionsScreen(
}
}
if (showTableImportDialog.value) {
// Export Dialog
if (showExportDialog) {
AlertDialog(
onDismissRequest = { showTableImportDialog.value = false },
title = { Text(stringResource(R.string.label_import_table_csv_excel)) },
onDismissRequest = { showExportDialog = false },
title = { Text("Export Categories") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
val columnCount = parsedTable.maxOfOrNull { it.size } ?: 0
// Column selectors
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 }
)
}
}
LazyColumn {
item {
Text(
"Select categories to export:",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 8.dp)
)
}
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 }
)
}
}
}
// Language selectors
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 }
items(categories) { category ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = selectedCategories.contains(category.id),
onCheckedChange = { checked ->
if (checked) {
selectedCategories.add(category.id)
} else {
selectedCategories.removeAt(selectedCategories.indexOf(category.id))
}
}
)
}
Column(modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.label_second_language))
SingleLanguageDropDown(
languageViewModel = languageViewModel,
selectedLanguage = selectedLangSecond,
onLanguageSelected = { selectedLangSecond = it }
Text(
text = category.name,
modifier = Modifier.padding(start = 8.dp)
)
}
}
// Header toggle
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = skipHeader, onCheckedChange = { skipHeader = it })
Spacer(modifier = Modifier.width(4.dp))
Text(stringResource(R.string.label_header_row))
}
// Previews
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 eu.gaudian.translator.model.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)) }
TextButton(
onClick = {
if (selectedCategories.size == 1) {
exportImportViewModel.exportCategory(selectedCategories.first())
} else if (selectedCategories.isNotEmpty()) {
// Simplified: export first selected category
exportImportViewModel.exportCategory(selectedCategories.first())
}
showExportDialog = false
selectedCategories.clear()
},
enabled = selectedCategories.isNotEmpty()
) {
Text("Export")
}
},
dismissButton = {
TextButton(onClick = { showTableImportDialog.value = false }) { Text(stringResource(R.string.label_cancel)) }
TextButton(onClick = {
showExportDialog = false
selectedCategories.clear()
}) {
Text("Cancel")
}
}
)
}
// Import Strategy Selection Dialog
if (showImportDialog) {
AlertDialog(
onDismissRequest = { showImportDialog = false },
title = { Text("Import Conflict Strategy") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
"Choose how to handle duplicates during import:",
style = MaterialTheme.typography.bodyMedium
)
ConflictStrategyOption(
strategy = ConflictStrategy.MERGE,
selected = selectedConflictStrategy == ConflictStrategy.MERGE,
onSelected = { selectedConflictStrategy = ConflictStrategy.MERGE },
title = "Merge (Recommended)",
description = "Keep existing items, merge progress and categories"
)
ConflictStrategyOption(
strategy = ConflictStrategy.SKIP,
selected = selectedConflictStrategy == ConflictStrategy.SKIP,
onSelected = { selectedConflictStrategy = ConflictStrategy.SKIP },
title = "Skip Duplicates",
description = "Keep existing items unchanged, only add new ones"
)
ConflictStrategyOption(
strategy = ConflictStrategy.REPLACE,
selected = selectedConflictStrategy == ConflictStrategy.REPLACE,
onSelected = { selectedConflictStrategy = ConflictStrategy.REPLACE },
title = "Replace Existing",
description = "Overwrite existing items with imported versions"
)
ConflictStrategyOption(
strategy = ConflictStrategy.RENAME,
selected = selectedConflictStrategy == ConflictStrategy.RENAME,
onSelected = { selectedConflictStrategy = ConflictStrategy.RENAME },
title = "Keep Both",
description = "Create duplicates with new IDs"
)
}
},
confirmButton = {
TextButton(onClick = { showImportDialog = false }) {
Text("Done")
}
}
)
}
// Conflict Strategy Confirmation Dialog
if (showConflictStrategyDialog && pendingImportJson != null) {
AlertDialog(
onDismissRequest = {
showConflictStrategyDialog = false
pendingImportJson = null
},
icon = { Icon(AppIcons.Warning, contentDescription = null) },
title = { Text("Confirm Import") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Import strategy: ${
when (selectedConflictStrategy) {
ConflictStrategy.MERGE -> "Merge"
ConflictStrategy.SKIP -> "Skip Duplicates"
ConflictStrategy.REPLACE -> "Replace"
ConflictStrategy.RENAME -> "Keep Both"
}
}")
Text(
when (selectedConflictStrategy) {
ConflictStrategy.MERGE -> "Existing items will be kept. Progress and categories will be merged intelligently."
ConflictStrategy.SKIP -> "Only new items will be added. Existing items remain unchanged."
ConflictStrategy.REPLACE -> "Existing items will be replaced with imported versions."
ConflictStrategy.RENAME -> "All imported items will be added as new entries."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
TextButton(onClick = { showImportDialog = true }) {
Text("Change Strategy")
}
}
},
confirmButton = {
TextButton(onClick = {
pendingImportJson?.let { json ->
exportImportViewModel.importFromJson(json, selectedConflictStrategy)
}
showConflictStrategyDialog = false
pendingImportJson = null
}) {
Text("Import")
}
},
dismissButton = {
TextButton(onClick = {
showConflictStrategyDialog = false
pendingImportJson = null
}) {
Text("Cancel")
}
}
)
}
// CSV Import Dialog
if (showTableImportDialog.value) {
val infoImportedItemsFrom = stringResource(R.string.info_imported_items_from)
CsvImportDialog(
showDialog = showTableImportDialog.value,
parsedTable = parsedTable,
languageViewModel = languageViewModel,
onDismiss = { showTableImportDialog.value = false },
onImport = { items ->
vocabularyViewModel.addVocabularyItems(items)
statusMessageService.showSuccessMessage("$infoImportedItemsFrom ${items.size}")
showTableImportDialog.value = false
},
statusMessageService = statusMessageService
)
}
}
}
}
@Composable
private fun ConflictStrategyOption(
strategy: ConflictStrategy,
selected: Boolean,
onSelected: () -> Unit,
title: String,
description: String,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier
.fillMaxWidth()
.clickable { onSelected() },
colors = CardDefaults.cardColors(
containerColor = if (selected) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
}
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = selected,
onClick = { onSelected() }
)
Column(
modifier = Modifier
.weight(1f)
.padding(start = 8.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Bold
)
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}

View File

@@ -12,10 +12,12 @@ import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -45,6 +47,7 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
@@ -62,13 +65,13 @@ import eu.gaudian.translator.view.composable.AppCard
import eu.gaudian.translator.view.composable.AppIcons
import eu.gaudian.translator.view.composable.AppOutlinedCard
import eu.gaudian.translator.view.dialogs.MissingLanguageDialog
import eu.gaudian.translator.view.vocabulary.widgets.AllVocabularyWidget
import eu.gaudian.translator.view.vocabulary.widgets.CategoryProgressWidget
import eu.gaudian.translator.view.vocabulary.widgets.DueTodayWidget
import eu.gaudian.translator.view.vocabulary.widgets.LevelWidget
import eu.gaudian.translator.view.vocabulary.widgets.StatusWidget
import eu.gaudian.translator.view.vocabulary.widgets.StreakWidget
import eu.gaudian.translator.view.vocabulary.widgets.WeeklyActivityChartWidget
import eu.gaudian.translator.view.stats.widgets.AllVocabularyWidget
import eu.gaudian.translator.view.stats.widgets.CategoryProgressWidget
import eu.gaudian.translator.view.stats.widgets.DueTodayWidget
import eu.gaudian.translator.view.stats.widgets.LevelWidget
import eu.gaudian.translator.view.stats.widgets.StatusWidget
import eu.gaudian.translator.view.stats.widgets.StreakWidget
import eu.gaudian.translator.view.stats.widgets.WeeklyActivityChartWidget
import eu.gaudian.translator.viewmodel.LanguageViewModel
import eu.gaudian.translator.viewmodel.ProgressViewModel
import eu.gaudian.translator.viewmodel.SettingsViewModel
@@ -203,12 +206,27 @@ fun StatsScreen(
}
)
Spacer(modifier = Modifier.padding(16.dp))
Row(
modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.label_stats),
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
}
LazyColumn(
state = lazyListState,
modifier = Modifier
.fillMaxSize()
.dragContainer(dragDropState),
contentPadding = PaddingValues(bottom = 160.dp)
contentPadding = PaddingValues( 8.dp)
) {
itemsIndexed(
items = orderedWidgets,

View File

@@ -1,4 +1,4 @@
package eu.gaudian.translator.view.vocabulary.widgets
package eu.gaudian.translator.view.stats.widgets
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box

View File

@@ -1,4 +1,4 @@
package eu.gaudian.translator.view.vocabulary.widgets
package eu.gaudian.translator.view.stats.widgets
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
@@ -290,7 +290,7 @@ fun CategoryProgressCircle(
}
}
@Composable
private fun ChartLegend() {
fun ChartLegend() {
Row(
modifier = Modifier
.fillMaxWidth()

View File

@@ -1,4 +1,4 @@
package eu.gaudian.translator.view.vocabulary.widgets
package eu.gaudian.translator.view.stats.widgets
import android.annotation.SuppressLint
import androidx.compose.foundation.clickable

View File

@@ -1,4 +1,4 @@
package eu.gaudian.translator.view.vocabulary.widgets
package eu.gaudian.translator.view.stats.widgets
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column

View File

@@ -1,4 +1,4 @@
package eu.gaudian.translator.view.vocabulary.widgets
package eu.gaudian.translator.view.stats.widgets
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable

View File

@@ -1,4 +1,4 @@
package eu.gaudian.translator.view.vocabulary.widgets
package eu.gaudian.translator.view.stats.widgets
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween

View File

@@ -1,4 +1,4 @@
package eu.gaudian.translator.view.vocabulary.widgets
package eu.gaudian.translator.view.stats.widgets
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -90,7 +90,7 @@ fun StatusWidget(
if (itemsWithoutGrammarCount > 0) {
StatusItem(
icon = AppIcons.Error,
text = stringResource(R.string.items_without_grammar_infos),
text = stringResource(R.string.label_items_without_grammar),
count = itemsWithoutGrammarCount,
onClick = onNavigateToNoGrammar,
color = MaterialTheme.colorScheme.error

View File

@@ -1,4 +1,4 @@
package eu.gaudian.translator.view.vocabulary.widgets
package eu.gaudian.translator.view.stats.widgets
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement

View File

@@ -0,0 +1,486 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.view.stats.widgets
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
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
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import eu.gaudian.translator.R
import eu.gaudian.translator.ui.theme.ThemePreviews
import eu.gaudian.translator.viewmodel.WeeklyActivityStat
import kotlinx.coroutines.delay
import kotlin.math.roundToInt
/**
* A widget that displays weekly activity statistics in a visually appealing smooth line chart.
* It's designed to be consistent with the app's modern UI style using the theme's colors.
*
* @param weeklyStats A list of [WeeklyActivityStat] for the last 7 days.
*/
@Composable
fun WeeklyActivityChartWidget(
weeklyStats: List<WeeklyActivityStat>
) {
val hasNoData = remember(weeklyStats) {
weeklyStats.all { it.completed == 0 && it.answeredRight == 0 }
}
if (hasNoData) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
Text(
text = stringResource(R.string.text_no_data_available),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
Column(
modifier = Modifier
.fillMaxWidth()
// Reduced horizontal padding to give the chart more space
.padding(vertical = 24.dp, horizontal = 12.dp)
) {
WeeklyChartLegend()
Spacer(modifier = Modifier.height(24.dp))
InteractiveLineChart(weeklyStats = weeklyStats)
Spacer(modifier = Modifier.height(24.dp))
ChartFooter(weeklyStats = weeklyStats)
}
}
}
@Composable
private fun InteractiveLineChart(weeklyStats: List<WeeklyActivityStat>) {
var selectedIndex by remember { mutableStateOf<Int?>(3) } // Default selection
val textMeasurer = rememberTextMeasurer()
val colorCompleted = MaterialTheme.colorScheme.primary
val colorCorrect = MaterialTheme.colorScheme.tertiary
val gridColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f)
val tooltipLineColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f)
val dotCenterColor = MaterialTheme.colorScheme.surfaceVariant
val tooltipBgColor = MaterialTheme.colorScheme.inverseSurface
val tooltipTextColor = MaterialTheme.colorScheme.inverseOnSurface
var startAnimation by remember { mutableStateOf(false) }
val animationProgress by animateFloatAsState(
targetValue = if (startAnimation) 1f else 0f,
animationSpec = tween(durationMillis = 1000),
label = "chartAnimation"
)
LaunchedEffect(Unit) {
delay(100)
startAnimation = true
}
val yAxisMax = remember(weeklyStats) {
val max = weeklyStats.flatMap { listOf(it.completed, it.answeredRight) }.maxOrNull() ?: 0
if (max < 10) 10 else ((max / 10) + 1) * 10
}
val yMax = yAxisMax.toFloat()
Row(modifier = Modifier.fillMaxWidth()) {
// Left Side: Y-Axis Amounts
Column(
modifier = Modifier
.height(180.dp)
// Reduced end padding to save space
.padding(end = 8.dp, top = 2.dp, bottom = 2.dp),
verticalArrangement = Arrangement.SpaceBetween,
horizontalAlignment = Alignment.End
) {
Text(
text = yAxisMax.toString(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = (yAxisMax / 2).toString(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "0",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Right Side: Chart Area
Column(modifier = Modifier.weight(1f)) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(180.dp)
.pointerInput(Unit) {
detectTapGestures { offset ->
val itemWidth = size.width / (weeklyStats.size - 1).coerceAtLeast(1)
selectedIndex = (offset.x / itemWidth).roundToInt().coerceIn(0, weeklyStats.lastIndex)
}
}
.pointerInput(Unit) {
detectHorizontalDragGestures { change, _ ->
val itemWidth = size.width / (weeklyStats.size - 1).coerceAtLeast(1)
selectedIndex = (change.position.x / itemWidth).roundToInt().coerceIn(0, weeklyStats.lastIndex)
}
}
) {
Canvas(modifier = Modifier.fillMaxSize()) {
val width = size.width
val height = size.height
val xSpacing = width / (weeklyStats.size - 1).coerceAtLeast(1)
drawLine(gridColor, Offset(0f, 0f), Offset(width, 0f), strokeWidth = 2f, pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f)))
drawLine(gridColor, Offset(0f, height / 2f), Offset(width, height / 2f), strokeWidth = 2f, pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f)))
drawLine(gridColor, Offset(0f, height), Offset(width, height), strokeWidth = 2f, pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f)))
if (animationProgress == 0f) return@Canvas
val pointsCompleted = weeklyStats.mapIndexed { i, stat ->
Offset(i * xSpacing, height - ((stat.completed * animationProgress) / yMax) * height)
}
val pointsCorrect = weeklyStats.mapIndexed { i, stat ->
Offset(i * xSpacing, height - ((stat.answeredRight * animationProgress) / yMax) * height)
}
// Define Paths
val pathCorrect = Path().apply { smoothCurve(pointsCorrect) }
val fillPathCorrect = Path().apply {
smoothCurve(pointsCorrect)
lineTo(width, height)
lineTo(0f, height)
close()
}
val pathCompleted = Path().apply { smoothCurve(pointsCompleted) }
val fillPathCompleted = Path().apply {
smoothCurve(pointsCompleted)
lineTo(width, height)
lineTo(0f, height)
close()
}
// Draw semi-transparent fills first
drawPath(
path = fillPathCompleted,
brush = Brush.verticalGradient(
colors = listOf(colorCompleted.copy(alpha = 0.25f), Color.Transparent),
startY = 0f,
endY = height
)
)
drawPath(
path = fillPathCorrect,
brush = Brush.verticalGradient(
colors = listOf(colorCorrect.copy(alpha = 0.25f), Color.Transparent),
startY = 0f,
endY = height
)
)
// Draw solid strokes on top of the fills
drawPath(
path = pathCorrect,
color = colorCorrect,
style = Stroke(width = 6f, pathEffect = PathEffect.dashPathEffect(floatArrayOf(12f, 12f), 0f))
)
drawPath(
path = pathCompleted,
color = colorCompleted,
style = Stroke(width = 8f)
)
// Interactive Highlights & Dual Separated Tooltips
selectedIndex?.let { index ->
val stat = weeklyStats[index]
val x = index * xSpacing
val yCompleted = height - ((stat.completed * animationProgress) / yMax) * height
val yCorrect = height - ((stat.answeredRight * animationProgress) / yMax) * height
// Vertical line marker
drawLine(
color = tooltipLineColor,
start = Offset(x, 0f),
end = Offset(x, height),
strokeWidth = 3f
)
// Dots on lines
drawCircle(color = dotCenterColor, radius = 12f, center = Offset(x, yCompleted))
drawCircle(color = colorCompleted, radius = 7f, center = Offset(x, yCompleted))
drawCircle(color = dotCenterColor, radius = 12f, center = Offset(x, yCorrect))
drawCircle(color = colorCorrect, radius = 7f, center = Offset(x, yCorrect))
// Measure text
val textStyle = TextStyle(color = tooltipTextColor, fontWeight = FontWeight.Bold, fontSize = 13.sp)
val textResCompleted = textMeasurer.measure(stat.completed.toString(), textStyle)
val textResCorrect = textMeasurer.measure(stat.answeredRight.toString(), textStyle)
val dotRadius = 5f
val gap = 6f
val padX = 12f
val padY = 8f
val w1 = padX * 2 + dotRadius * 2 + gap + textResCompleted.size.width
val h1 = padY * 2 + textResCompleted.size.height
val w2 = padX * 2 + dotRadius * 2 + gap + textResCorrect.size.width
val h2 = padY * 2 + textResCorrect.size.height
// Tooltip Overlap Prevention Logic
val completedIsHigher = yCompleted <= yCorrect
var yPosCompleted = if (completedIsHigher) yCompleted - h1 - 12f else yCompleted + 12f
var yPosCorrect = if (completedIsHigher) yCorrect + 12f else yCorrect - h2 - 12f
// Prevent clipping out of canvas bounds natively first
if (yPosCompleted < 0f && completedIsHigher) yPosCompleted = 0f
if (yPosCorrect < 0f && !completedIsHigher) yPosCorrect = 0f
if (yPosCompleted + h1 > height && !completedIsHigher) yPosCompleted = height - h1
if (yPosCorrect + h2 > height && completedIsHigher) yPosCorrect = height - h2
// Overlap resolution
val topRectY = minOf(yPosCompleted, yPosCorrect)
val topRectH = if (topRectY == yPosCompleted) h1 else h2
val bottomRectY = maxOf(yPosCompleted, yPosCorrect)
val gapBetweenTooltips = 8f
if (topRectY + topRectH + gapBetweenTooltips > bottomRectY) {
val midPointY = (yCompleted + yCorrect) / 2f
val adjustedTopY = midPointY - (topRectH + gapBetweenTooltips / 2f)
val adjustedBottomY = midPointY + (gapBetweenTooltips / 2f)
if (topRectY == yPosCompleted) {
yPosCompleted = adjustedTopY
yPosCorrect = adjustedBottomY
} else {
yPosCorrect = adjustedTopY
yPosCompleted = adjustedBottomY
}
}
// Final Canvas Bounds Check post-resolution
val finalMinY = minOf(yPosCompleted, yPosCorrect)
if (finalMinY < 0f) {
yPosCompleted -= finalMinY
yPosCorrect -= finalMinY
}
val finalMaxY = maxOf(yPosCompleted + h1, yPosCorrect + h2)
if (finalMaxY > height) {
val shift = finalMaxY - height
yPosCompleted -= shift
yPosCorrect -= shift
}
// Draw Completed Tooltip
val t1X = (x - w1 / 2f).coerceIn(0f, width - w1)
drawRoundRect(color = tooltipBgColor, topLeft = Offset(t1X, yPosCompleted), size = Size(w1, h1), cornerRadius = CornerRadius(16f, 16f))
drawCircle(color = colorCompleted, radius = dotRadius, center = Offset(t1X + padX + dotRadius, yPosCompleted + h1 / 2f))
drawText(textLayoutResult = textResCompleted, topLeft = Offset(t1X + padX + dotRadius * 2 + gap, yPosCompleted + padY))
// Draw Correct Tooltip
val t2X = (x - w2 / 2f).coerceIn(0f, width - w2)
drawRoundRect(color = tooltipBgColor, topLeft = Offset(t2X, yPosCorrect), size = Size(w2, h2), cornerRadius = CornerRadius(16f, 16f))
drawCircle(color = colorCorrect, radius = dotRadius, center = Offset(t2X + padX + dotRadius, yPosCorrect + h2 / 2f))
drawText(textLayoutResult = textResCorrect, topLeft = Offset(t2X + padX + dotRadius * 2 + gap, yPosCorrect + padY))
}
}
}
Spacer(modifier = Modifier.height(16.dp))
// X-Axis Labels (Freed from fixed widths, prevented from wrapping)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
weeklyStats.forEachIndexed { index, stat ->
val isSelected = index == selectedIndex
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = stat.day.uppercase().take(3) + ".",
color = if (isSelected) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 11.sp, // Slightly smaller to ensure fit across all devices
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
maxLines = 1,
softWrap = false // Prevents the text from splitting into multiple lines
)
if (isSelected) {
Spacer(modifier = Modifier.height(4.dp))
Box(
modifier = Modifier
.height(2.dp)
.width(20.dp)
.background(MaterialTheme.colorScheme.primary)
)
} else {
// Invisible spacer to prevent layout jumping when line appears
Spacer(modifier = Modifier.height(6.dp))
}
}
}
}
}
}
}
private fun Path.smoothCurve(points: List<Offset>) {
if (points.isEmpty()) return
moveTo(points.first().x, points.first().y)
for (i in 1 until points.size) {
val prev = points[i - 1]
val curr = points[i]
val controlX = (prev.x + curr.x) / 2f
cubicTo(
controlX, prev.y,
controlX, curr.y,
curr.x, curr.y
)
}
}
@Composable
private fun WeeklyChartLegend() {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
LegendItem(color = MaterialTheme.colorScheme.primary, label = stringResource(R.string.label_completed).uppercase())
LegendItem(color = MaterialTheme.colorScheme.tertiary, label = stringResource(R.string.label_correct).uppercase())
}
}
@Composable
private fun LegendItem(color: Color, label: String) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(10.dp)
.background(color, shape = CircleShape)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = label,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold,
fontSize = 11.sp,
letterSpacing = 0.5.sp
)
}
}
@Composable
private fun ChartFooter(weeklyStats: List<WeeklyActivityStat>) {
val bestDay = remember(weeklyStats) {
weeklyStats.maxByOrNull { it.completed + it.answeredRight }?.day?.uppercase() ?: ""
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "Melhor Dia:",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 13.sp
)
Spacer(modifier = Modifier.width(8.dp))
Box(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f))
.padding(horizontal = 10.dp, vertical = 6.dp)
) {
Text(
text = bestDay,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Bold,
fontSize = 12.sp
)
}
}
}
}
@ThemePreviews
@Composable
fun WeeklyActivityChartWidgetPreview() {
val sampleStats = listOf(
WeeklyActivityStat("Seg", 30, 15, 10),
WeeklyActivityStat("Ter", 45, 20, 12),
WeeklyActivityStat("Qua", 80, 25, 15),
WeeklyActivityStat("Qui", 84, 35, 18),
WeeklyActivityStat("Sex", 50, 40, 22),
WeeklyActivityStat("Sáb", 70, 30, 20),
WeeklyActivityStat("Dom", 60, 25, 18)
)
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(16.dp),
contentAlignment = Alignment.Center
) {
WeeklyActivityChartWidget(weeklyStats = sampleStats)
}
}

View File

@@ -1,6 +1,6 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.view.vocabulary.widgets
package eu.gaudian.translator.view.stats.widgets
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement

View File

@@ -6,6 +6,7 @@ import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.RepeatMode
@@ -58,6 +59,7 @@ import eu.gaudian.translator.view.LocalConnectionConfigured
import eu.gaudian.translator.view.NoConnectionScreen
import eu.gaudian.translator.view.composable.AppCard
import eu.gaudian.translator.view.composable.AppOutlinedCard
import eu.gaudian.translator.view.composable.Screen
import eu.gaudian.translator.view.dialogs.AddVocabularyDialog
import eu.gaudian.translator.view.hints.HintDefinition
import eu.gaudian.translator.view.settings.SettingsRoutes
@@ -88,7 +90,10 @@ fun TranslationScreen(
if (isInitializationComplete && !connectionConfigured) {
NoConnectionScreen(onSettingsClick = { navController.navigate(SettingsRoutes.API_KEY) })
NoConnectionScreen(
onSettingsClick = { navController.navigate(SettingsRoutes.API_KEY) },
navController = navController
)
return
}
@@ -108,7 +113,7 @@ fun TranslationScreen(
onSettingsClick = onSettingsClick,
onNavigateBack = {
if (!navController.popBackStack()) {
navController.navigate(eu.gaudian.translator.view.composable.Screen.Home.route) {
navController.navigate(Screen.Home.route) {
launchSingleTop = true
restoreState = false
}
@@ -182,7 +187,7 @@ private fun LoadedTranslationContent(
AppCard(modifier = Modifier.padding(8.dp, end = 8.dp, bottom = 8.dp, top = 0.dp)) {
val configuration = LocalConfiguration.current
val isLandscape = configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE
val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
if (isLandscape) {
Row(modifier = Modifier.fillMaxSize()) {

View File

@@ -1,671 +0,0 @@
@file:Suppress("HardCodedStringLiteral", "AssignedValueIsNeverRead", "UnusedMaterial3ScaffoldPaddingParameter")
package eu.gaudian.translator.view.vocabulary
import android.annotation.SuppressLint
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
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.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
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.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController
import eu.gaudian.translator.R
import eu.gaudian.translator.model.VocabularyStage
import eu.gaudian.translator.model.WidgetType
import eu.gaudian.translator.utils.findActivity
import eu.gaudian.translator.view.composable.AppCard
import eu.gaudian.translator.view.composable.AppIcons
import eu.gaudian.translator.view.composable.AppOutlinedCard
import eu.gaudian.translator.view.dialogs.MissingLanguageDialog
import eu.gaudian.translator.view.vocabulary.widgets.AllVocabularyWidget
import eu.gaudian.translator.view.vocabulary.widgets.CategoryProgressWidget
import eu.gaudian.translator.view.vocabulary.widgets.DueTodayWidget
import eu.gaudian.translator.view.vocabulary.widgets.LevelWidget
import eu.gaudian.translator.view.vocabulary.widgets.StatusWidget
import eu.gaudian.translator.view.vocabulary.widgets.StreakWidget
import eu.gaudian.translator.view.vocabulary.widgets.WeeklyActivityChartWidget
import eu.gaudian.translator.viewmodel.LanguageViewModel
import eu.gaudian.translator.viewmodel.ProgressViewModel
import eu.gaudian.translator.viewmodel.SettingsViewModel
import eu.gaudian.translator.viewmodel.VocabularyViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
@SuppressLint("FrequentlyChangingValue")
@Composable
fun DashboardContent(
navController: NavController,
onShowCustomExerciseDialog: () -> Unit,
startDailyExercise: (Boolean) -> Unit,
onNavigateToCategoryDetail: (Int) -> Unit,
onNavigateToCategoryList: () -> Unit,
onShowWordPairExerciseDialog: () -> Unit,
onScroll: (Boolean) -> Unit = {},
) {
val activity = LocalContext.current.findActivity()
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
val settingsViewModel: SettingsViewModel = hiltViewModel(viewModelStoreOwner = activity)
val progressViewModel: ProgressViewModel = hiltViewModel(viewModelStoreOwner = activity)
var showMissingLanguageDialog by remember { mutableStateOf(false) }
var selectedMissingLanguageId by remember { mutableStateOf<Int?>(null) }
val vocabularyViewModel: VocabularyViewModel = hiltViewModel(viewModelStoreOwner = activity)
val affectedItems by remember(selectedMissingLanguageId) {
selectedMissingLanguageId?.let {
vocabularyViewModel.getItemsForLanguage(it)
} ?: flowOf(emptyList())
}.collectAsState(initial = emptyList())
if (showMissingLanguageDialog && selectedMissingLanguageId != null) {
MissingLanguageDialog(
showDialog = true,
missingLanguageId = selectedMissingLanguageId!!,
affectedItems = affectedItems,
onDismiss = { showMissingLanguageDialog = false },
onDelete = { items ->
vocabularyViewModel.deleteVocabularyItemsById(items.map { it.id })
showMissingLanguageDialog = false
},
onReplace = { oldId, newId ->
vocabularyViewModel.replaceLanguageId(oldId, newId)
showMissingLanguageDialog = false
},
onCreate = { newLanguage ->
languageViewModel.addCustomLanguage(newLanguage)
},
languageViewModel = languageViewModel
)
}
AppOutlinedCard {
// We collect the order from DB initially
val initialWidgetOrder by settingsViewModel.widgetOrder.collectAsState(initial = null)
val collapsedWidgetIds by settingsViewModel.collapsedWidgetIds.collectAsState(initial = emptySet())
val dashboardScrollState by settingsViewModel.dashboardScrollState.collectAsState()
val scope = rememberCoroutineScope()
if (initialWidgetOrder == null) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(vertical = 64.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else {
// BEST PRACTICE: Use a SnapshotStateList for immediate UI updates.
// We only initialize this once, so DB updates don't reset the list while dragging.
val orderedWidgets = remember { mutableStateListOf<WidgetType>() }
// Sync with DB only on first load
LaunchedEffect(initialWidgetOrder) {
if (orderedWidgets.isEmpty() && !initialWidgetOrder.isNullOrEmpty()) {
orderedWidgets.addAll(initialWidgetOrder!!)
} else if (orderedWidgets.isEmpty()) {
orderedWidgets.addAll(WidgetType.DEFAULT_ORDER)
}
}
val lazyListState = rememberLazyListState(
initialFirstVisibleItemIndex = dashboardScrollState.first,
initialFirstVisibleItemScrollOffset = dashboardScrollState.second
)
// Save scroll state
LaunchedEffect(lazyListState.firstVisibleItemIndex, lazyListState.firstVisibleItemScrollOffset) {
settingsViewModel.saveDashboardScrollState(
lazyListState.firstVisibleItemIndex,
lazyListState.firstVisibleItemScrollOffset
)
}
// Detect scroll and notify parent
LaunchedEffect(lazyListState.isScrollInProgress) {
onScroll(lazyListState.isScrollInProgress)
}
DisposableEffect(Unit) {
onDispose {
settingsViewModel.saveDashboardScrollState(
lazyListState.firstVisibleItemIndex,
lazyListState.firstVisibleItemScrollOffset
)
}
}
// --- Robust Drag and Drop State ---
val dragDropState = rememberDragDropState(
lazyListState = lazyListState,
onSwap = { fromIndex, toIndex ->
// Swap data immediately for responsiveness
orderedWidgets.apply {
add(toIndex, removeAt(fromIndex))
}
},
onDragEnd = {
// Persist to DB only when user drops
settingsViewModel.saveWidgetOrder(orderedWidgets.toList())
}
)
LazyColumn(
state = lazyListState,
modifier = Modifier
.fillMaxSize()
.dragContainer(dragDropState),
contentPadding = PaddingValues(bottom = 160.dp)
) {
itemsIndexed(
items = orderedWidgets,
key = { _, widget -> widget.id }
) { index, widgetType ->
val isDragging = index == dragDropState.draggingItemIndex
// Calculate translation: distinct logic for dragged vs. stationary items
val translationY = if (isDragging) {
dragDropState.draggingItemOffset
} else {
0f
}
Box(
modifier = Modifier
.zIndex(if (isDragging) 1f else 0f)
.graphicsLayer {
this.translationY = translationY
this.shadowElevation = if (isDragging) 8.dp.toPx() else 0f
this.scaleX = if (isDragging) 1.02f else 1f
this.scaleY = if (isDragging) 1.02f else 1f
}
// CRITICAL FIX: Only apply animation to items NOT being dragged.
// This prevents the "flicker" by stopping the layout animation
// from fighting your manual drag offset.
.then(
if (!isDragging) {
Modifier.animateItem(
placementSpec = spring(
stiffness = Spring.StiffnessLow,
visibilityThreshold = IntOffset.VisibilityThreshold
)
)
} else {
Modifier
}
)
) {
WidgetContainer(
widgetType = widgetType,
isExpanded = widgetType.id !in collapsedWidgetIds,
onExpandedChange = { newExpandedState ->
scope.launch {
settingsViewModel.setWidgetExpandedState(widgetType.id, newExpandedState)
}
},
onDragStart = { dragDropState.onDragStart(index) },
onDrag = { dragAmount -> dragDropState.onDrag(dragAmount) },
onDragEnd = { dragDropState.onDragEnd() },
onDragCancel = { dragDropState.onDragInterrupted() },
modifier = Modifier.fillMaxWidth()
) {
LazyWidget(
widgetType = widgetType,
navController = navController,
vocabularyViewModel = vocabularyViewModel,
progressViewModel = progressViewModel,
onShowCustomExerciseDialog = onShowCustomExerciseDialog,
startDailyExercise = startDailyExercise,
onNavigateToCategoryDetail = onNavigateToCategoryDetail,
onNavigateToCategoryList = onNavigateToCategoryList,
onShowWordPairExerciseDialog = onShowWordPairExerciseDialog,
onMissingLanguage = { missingId ->
selectedMissingLanguageId = missingId
showMissingLanguageDialog = true
}
)
}
}
}
}
}
}
}
@Composable
private fun WidgetContainer(
widgetType: WidgetType,
isExpanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
onDragStart: () -> Unit,
onDrag: (Float) -> Unit,
onDragEnd: () -> Unit,
onDragCancel: () -> Unit,
content: @Composable () -> Unit
) {
AppCard(
modifier = modifier.padding(horizontal = 8.dp, vertical = 4.dp),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.animateContentSize(animationSpec = tween(300, easing = FastOutSlowInEasing))
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(widgetType.titleRes),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f)
)
IconButton(onClick = { onExpandedChange(!isExpanded) }) {
Icon(
imageVector = if (isExpanded) AppIcons.ArrowDropUp
else AppIcons.ArrowDropDown,
contentDescription = if (isExpanded) stringResource(R.string.text_collapse_widget)
else stringResource(R.string.text_expand_widget)
)
}
// Drag Handle with specific pointer input
Icon(
imageVector = AppIcons.DragHandle,
contentDescription = stringResource(R.string.text_drag_to_reorder),
tint = if (isExpanded) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
else MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.padding(end = 8.dp, start = 8.dp)
.pointerInput(Unit) {
detectDragGestures(
onDragStart = { _ -> onDragStart() },
onDrag = { change, dragAmount ->
change.consume()
onDrag(dragAmount.y)
},
onDragEnd = { onDragEnd() },
onDragCancel = { onDragCancel() }
)
}
)
}
if (isExpanded) {
content()
}
}
}
}
// --------------------------------------------------------------------------------
// Fixed Drag and Drop Logic
// --------------------------------------------------------------------------------
@Composable
fun rememberDragDropState(
lazyListState: LazyListState,
onSwap: (Int, Int) -> Unit,
onDragEnd: () -> Unit
): DragDropState {
val scope = rememberCoroutineScope()
return remember(lazyListState, scope) {
DragDropState(
state = lazyListState,
onSwap = onSwap,
onDragFinished = onDragEnd,
scope = scope
)
}
}
fun Modifier.dragContainer(dragDropState: DragDropState): Modifier {
return this.pointerInput(dragDropState) {
// Just allows the modifier to exist in the chain, logic is in the handle
}
}
class DragDropState(
private val state: LazyListState,
private val onSwap: (Int, Int) -> Unit,
private val onDragFinished: () -> Unit,
private val scope: CoroutineScope
) {
var draggingItemIndex by mutableIntStateOf(-1)
private set
private val _draggingItemOffset = Animatable(0f)
val draggingItemOffset: Float
get() = _draggingItemOffset.value
private val scrollChannel = Channel<Float>(Channel.CONFLATED)
init {
scope.launch {
for (scrollAmount in scrollChannel) {
if (scrollAmount != 0f) {
state.scrollBy(scrollAmount)
checkSwap()
}
}
}
}
fun onDragStart(index: Int) {
draggingItemIndex = index
scope.launch { _draggingItemOffset.snapTo(0f) }
}
fun onDrag(dragAmount: Float) {
if (draggingItemIndex == -1) return
scope.launch {
_draggingItemOffset.snapTo(_draggingItemOffset.value + dragAmount)
checkSwap()
checkOverscroll()
}
}
private fun checkSwap() {
val draggedIndex = draggingItemIndex
if (draggedIndex == -1) return
val visibleItems = state.layoutInfo.visibleItemsInfo
val draggedItemInfo = visibleItems.find { it.index == draggedIndex } ?: return
// Calculate the visual center of the dragged item
val draggedCenter = draggedItemInfo.offset + (draggedItemInfo.size / 2) + _draggingItemOffset.value
// Find a target to swap with
// FIX: We strictly check if we have crossed the CENTER of the target item.
// This acts as a hysteresis buffer to prevent flickering at the edges.
val targetItem = visibleItems.find { item ->
item.index != draggedIndex &&
draggedCenter > item.offset &&
draggedCenter < (item.offset + item.size)
}
if (targetItem != null) {
// Extra Check: Ensure we have actually crossed the midpoint of the target
val targetCenter = itemCenter(targetItem.offset, targetItem.size)
val isAboveAndMovingDown = draggedIndex < targetItem.index && draggedCenter > targetCenter
val isBelowAndMovingUp = draggedIndex > targetItem.index && draggedCenter < targetCenter
if (isAboveAndMovingDown || isBelowAndMovingUp) {
val targetIndex = targetItem.index
// 1. Swap Data
onSwap(draggedIndex, targetIndex)
// 2. Adjust Offset
// We calculate the physical distance the item moved in the layout (e.g. 150px).
// We subtract this from the current drag offset to keep the item visually stationary under the finger.
val layoutJumpDistance = (targetItem.offset - draggedItemInfo.offset).toFloat()
scope.launch {
_draggingItemOffset.snapTo(_draggingItemOffset.value - layoutJumpDistance)
}
// 3. Update Index
draggingItemIndex = targetIndex
}
}
}
private fun itemCenter(offset: Int, size: Int): Float {
return offset + (size / 2f)
}
private fun checkOverscroll() {
val draggedIndex = draggingItemIndex
if (draggedIndex == -1) {
scrollChannel.trySend(0f)
return
}
val layoutInfo = state.layoutInfo
val visibleItems = layoutInfo.visibleItemsInfo
val draggedItemInfo = visibleItems.find { it.index == draggedIndex } ?: return
val viewportStart = layoutInfo.viewportStartOffset
val viewportEnd = layoutInfo.viewportEndOffset
// Increased threshold slightly for smoother top-edge scrolling
val boundsStart = viewportStart + (viewportEnd * 0.15f)
val boundsEnd = viewportEnd - (viewportEnd * 0.15f)
val itemTop = draggedItemInfo.offset + _draggingItemOffset.value
val itemBottom = itemTop + draggedItemInfo.size
val scrollAmount = when {
itemTop < boundsStart -> -10f // Slower, more controlled scroll speed
itemBottom > boundsEnd -> 10f
else -> 0f
}
scrollChannel.trySend(scrollAmount)
}
fun onDragEnd() {
resetDrag()
onDragFinished()
}
fun onDragInterrupted() {
resetDrag()
}
private fun resetDrag() {
draggingItemIndex = -1
scrollChannel.trySend(0f)
scope.launch { _draggingItemOffset.snapTo(0f) }
}
}
// --------------------------------------------------------------------------------
// Remainder of your existing components
// --------------------------------------------------------------------------------
@Composable
private fun LazyWidget(
widgetType: WidgetType,
navController: NavController,
vocabularyViewModel: VocabularyViewModel,
progressViewModel: ProgressViewModel,
onShowCustomExerciseDialog: () -> Unit,
startDailyExercise: (Boolean) -> Unit,
onNavigateToCategoryDetail: (Int) -> Unit,
onNavigateToCategoryList: () -> Unit,
onShowWordPairExerciseDialog: () -> Unit,
onMissingLanguage: (Int) -> Unit
) {
when (widgetType) {
WidgetType.Status -> LazyStatusWidget(
vocabularyViewModel = vocabularyViewModel,
onNavigateToNew = { navController.navigate("vocabulary_sorting?mode=NEW") },
onNavigateToDuplicates = { navController.navigate("vocabulary_sorting?mode=DUPLICATES") },
onNavigateToFaulty = { navController.navigate("vocabulary_sorting?mode=FAULTY") },
onNavigateToNoGrammar = { navController.navigate("no_grammar_items") },
onNavigateToMissingLanguage = onMissingLanguage
)
else -> {
// Regular widgets that load immediately
when (widgetType) {
WidgetType.Streak -> StreakWidget(
streak = progressViewModel.streak.collectAsState(initial = 0).value,
lastSevenDays = progressViewModel.lastSevenDays.collectAsState().value,
dueTodayCount = progressViewModel.dueTodayCount.collectAsState().value,
wordsCompleted = progressViewModel.totalWordsCompleted.collectAsState().value,
onStatisticsClicked = { navController.navigate("vocabulary_heatmap") }
)
WidgetType.WeeklyActivityChart -> WeeklyActivityChartWidget(
weeklyStats = progressViewModel.weeklyActivityStats.collectAsState().value
)
WidgetType.AllVocabulary -> AllVocabularyWidget(
vocabularyViewModel = vocabularyViewModel,
onOpenAllVocabulary = { navController.navigate("vocabulary_list/false/null") },
onStageClicked = { stage -> navController.navigate("vocabulary_list/false/$stage") }
)
WidgetType.DueToday -> DueTodayWidget(
vocabularyViewModel = vocabularyViewModel,
onStageClicked = { stage -> navController.navigate("vocabulary_list/false/$stage") }
)
WidgetType.CategoryProgress -> CategoryProgressWidget(
onCategoryClicked = { category ->
category?.let { onNavigateToCategoryDetail(it.id) }
},
onViewAllClicked = onNavigateToCategoryList
)
WidgetType.Levels -> LevelWidget(
totalWords = vocabularyViewModel.vocabularyItems.collectAsState().value.size,
learnedWords = vocabularyViewModel.stageStats.collectAsState().value
.firstOrNull { it.stage == VocabularyStage.LEARNED }?.itemCount ?: 0,
onNavigateToProgress = { navController.navigate("language_progress") }
)
}
}
}
}
@Composable
private fun LazyStatusWidget(
vocabularyViewModel: VocabularyViewModel,
onNavigateToNew: () -> Unit,
onNavigateToDuplicates: () -> Unit,
onNavigateToFaulty: () -> Unit,
onNavigateToNoGrammar: () -> Unit,
onNavigateToMissingLanguage: (Int) -> Unit
) {
var isLoading by remember { mutableStateOf(true) }
// Collect all flows asynchronously
val newItemsCount by vocabularyViewModel.newItemsCount.collectAsState()
val duplicateCount by vocabularyViewModel.duplicateItemsCount.collectAsState()
val faultyItemsCount by vocabularyViewModel.faultyItemsCount.collectAsState()
val itemsWithoutGrammarCount by vocabularyViewModel.itemsWithoutGrammarCount.collectAsState()
val missingLanguageInfo by vocabularyViewModel.missingLanguageInfo.collectAsState()
LaunchedEffect(
newItemsCount,
duplicateCount,
faultyItemsCount,
itemsWithoutGrammarCount,
missingLanguageInfo
) {
delay(100)
isLoading = false
}
if (isLoading) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 32.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(modifier = Modifier.padding(16.dp))
}
} else {
StatusWidget(
onNavigateToNew = onNavigateToNew,
onNavigateToDuplicates = onNavigateToDuplicates,
onNavigateToFaulty = onNavigateToFaulty,
onNavigateToNoGrammar = onNavigateToNoGrammar,
onNavigateToMissingLanguage = onNavigateToMissingLanguage
)
}
}
@Preview
@Composable
fun DashboardContentPreview() {
val navController = rememberNavController()
DashboardContent(
navController = navController,
onShowCustomExerciseDialog = {},
onNavigateToCategoryDetail = {},
startDailyExercise = {},
onNavigateToCategoryList = {},
onShowWordPairExerciseDialog = {},
)
}
@Preview
@Composable
fun WidgetContainerPreview() {
WidgetContainer(
widgetType = WidgetType.Streak,
isExpanded = true,
onExpandedChange = {},
onDragStart = { } ,
onDrag = { },
onDragEnd = { },
onDragCancel = { }
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
Text("Preview Content")
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -67,7 +67,7 @@ import eu.gaudian.translator.view.composable.AppTopAppBar
import eu.gaudian.translator.viewmodel.ProgressViewModel
@Composable
fun LanguageProgressScreen(navController: NavController) {
fun LanguageJourneyScreen(navController: NavController) {
val activity = LocalContext.current.findActivity()
val progressViewModel : ProgressViewModel = hiltViewModel(activity)
@@ -379,6 +379,6 @@ private fun LevelDetailDialog(level: MyAppLanguageLevel, onDismiss: () -> Unit)
@Preview(showBackground = true)
@Composable
fun LanguageProgressScreenPreview() {
LanguageProgressScreen(navController = NavController(LocalContext.current))
fun LanguageJourneyScreenPreview() {
LanguageJourneyScreen(navController = NavController(LocalContext.current))
}

View File

@@ -1,7 +1,5 @@
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
@@ -16,38 +14,30 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.material.icons.filled.DriveFolderUpload
import androidx.compose.material.icons.filled.EditNote
import androidx.compose.material3.AlertDialog
import androidx.compose.material.icons.filled.Settings
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.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
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
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
@@ -56,16 +46,15 @@ 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.LocalConnectionConfigured
import eu.gaudian.translator.view.NavigationRoutes
import eu.gaudian.translator.view.composable.AppButton
import eu.gaudian.translator.view.composable.AppCard
import eu.gaudian.translator.view.composable.AppIconContainer
import eu.gaudian.translator.view.composable.AppIcons
import eu.gaudian.translator.view.composable.AppOutlinedButton
import eu.gaudian.translator.view.composable.AppOutlinedTextField
import eu.gaudian.translator.view.composable.AppSlider
import eu.gaudian.translator.view.composable.AppTopAppBar
import eu.gaudian.translator.view.composable.InspiringSearchField
@@ -74,6 +63,7 @@ import eu.gaudian.translator.view.composable.SourceLanguageDropdown
import eu.gaudian.translator.view.composable.TargetLanguageDropdown
import eu.gaudian.translator.view.hints.HintDefinition
import eu.gaudian.translator.view.library.VocabularyCard
import eu.gaudian.translator.view.settings.SettingsRoutes
import eu.gaudian.translator.viewmodel.LanguageViewModel
import eu.gaudian.translator.viewmodel.VocabularyViewModel
import kotlinx.coroutines.launch
@@ -104,109 +94,12 @@ 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) }
val recentlyAdded = remember(recentItems) {
recentItems.sortedByDescending { it.id }.take(4)
}
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 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
} else {
statusMessageService.showErrorById(StatusMessageId.ERROR_PARSING_TABLE)
}
}
} catch (_: Exception) {
statusMessageService.showErrorById(StatusMessageId.ERROR_PARSING_TABLE_WITH_REASON)
}
}
}
)
Box(
modifier = modifier
.fillMaxSize()
@@ -227,6 +120,14 @@ fun NewWordScreen(
Spacer(modifier = Modifier.height(16.dp))
// Explore Packs - Prominent full-width card at top
ExplorePacksProminentCard(
onClick = { navController.navigate(NavigationRoutes.EXPLORE_PACKS) }
)
Spacer(modifier = Modifier.height(24.dp))
// AI Generator Card
AIGeneratorCard(
category = category,
onCategoryChange = { category = it },
@@ -242,10 +143,12 @@ fun NewWordScreen(
}
}
},
navController = navController,
)
Spacer(modifier = Modifier.height(24.dp))
// Add Manually Card
AddManuallyCard(
languageViewModel = languageViewModel,
vocabularyViewModel = vocabularyViewModel,
@@ -253,19 +156,11 @@ fun NewWordScreen(
Spacer(modifier = Modifier.height(24.dp))
BottomActionCardsRow(
onImportCsvClick = {
// Import CSV - Full width card at bottom
ImportCsvCard(
onClick = {
@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"
)
)
navController.navigate("settings_vocabulary_repository_options")
}
)
@@ -310,123 +205,6 @@ fun NewWordScreen(
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))
eu.gaudian.translator.view.composable.SingleLanguageDropDown(
languageViewModel = languageViewModel,
selectedLanguage = selectedLangFirst,
onLanguageSelected = { selectedLangFirst = it }
)
}
Column(modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.label_second_language))
eu.gaudian.translator.view.composable.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 = {
TextButton(onClick = {
if (selectedColFirst == selectedColSecond) {
statusMessageService.showErrorById(StatusMessageId.ERROR_SELECT_TWO_COLUMNS)
return@TextButton
}
val langA = selectedLangFirst
val langB = selectedLangSecond
if (langA == null || langB == null) {
statusMessageService.showErrorById(StatusMessageId.ERROR_SELECT_LANGUAGES)
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.showErrorById(StatusMessageId.ERROR_NO_ROWS_TO_IMPORT)
return@TextButton
}
vocabularyViewModel.addVocabularyItems(items)
statusMessageService.showSuccessById(StatusMessageId.SUCCESS_ITEMS_IMPORTED)
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) ---
@@ -440,104 +218,156 @@ fun AIGeneratorCard(
languageViewModel: LanguageViewModel,
isGenerating: Boolean,
onGenerate: () -> Unit,
navController: NavHostController,
modifier: Modifier = Modifier
) {
val icon = Icons.Default.AutoAwesome
val hints = stringArrayResource(R.array.vocabulary_hints)
AppCard(
modifier = modifier.fillMaxWidth(),
title = stringResource(R.string.label_ai_generator),
icon = icon,
hintContent = HintDefinition.VOCABULARY_GENERATE_AI.hint(),
) {
Column(modifier = Modifier.padding(8.dp)) {
val connectionConfigured = LocalConnectionConfigured.current
Text(
text = stringResource(R.string.text_search_term),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(8.dp))
InspiringSearchField(
value = category,
hints = hints,
onValueChange = onCategoryChange
)
Spacer(modifier = Modifier.height(24.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(),
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))
Text(
text = stringResource(R.string.text_select_amount),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(8.dp))
AppSlider(
value = amount,
onValueChange = onAmountChange,
valueRange = 1f..25f,
steps = 24,
modifier = Modifier.fillMaxWidth()
)
Text(
text = stringResource(R.string.text_amount_2d, amount.toInt()),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 8.dp)
)
Spacer(modifier = Modifier.height(32.dp))
if (connectionConfigured) {
// Show the normal AI generator card
val icon = Icons.Default.AutoAwesome
val hints = stringArrayResource(R.array.vocabulary_hints)
AppCard(
modifier = modifier.fillMaxWidth(),
title = stringResource(R.string.label_ai_generator),
icon = icon,
hintContent = HintDefinition.VOCABULARY_GENERATE_AI.hint(),
) {
Column(modifier = Modifier.padding(8.dp)) {
if (isGenerating) {
Text(
text = stringResource(R.string.text_search_term),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(8.dp))
InspiringSearchField(
value = category,
hints = hints,
onValueChange = onCategoryChange
)
Spacer(modifier = Modifier.height(24.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(),
horizontalArrangement = Arrangement.Center
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f))
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
CircularProgressIndicator()
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(16.dp))
}
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(R.string.text_select_amount),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(8.dp))
AppSlider(
value = amount,
onValueChange = onAmountChange,
valueRange = 1f..25f,
steps = 24,
modifier = Modifier.fillMaxWidth()
)
Text(
text = stringResource(R.string.text_amount_2d, amount.toInt()),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 8.dp)
)
Spacer(modifier = Modifier.height(32.dp))
AppButton(
onClick = onGenerate,
if (isGenerating) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
CircularProgressIndicator()
}
Spacer(modifier = Modifier.height(16.dp))
}
AppButton(
onClick = onGenerate,
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
enabled = category.isNotBlank() && !isGenerating
) {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Icon(imageVector = Icons.Default.AutoAwesome, contentDescription = null, modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.text_generate),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
}
}
}
}
} else {
// Show the "configure connection" card when not configured
AppCard(
modifier = modifier.fillMaxWidth(),
title = stringResource(R.string.label_ai_generator),
icon = Icons.Default.AutoAwesome,
hintContent = HintDefinition.VOCABULARY_GENERATE_AI.hint(),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
enabled = category.isNotBlank() && !isGenerating
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
Text(
text = stringResource(R.string.text_ai_generator_requires_configuration),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
AppButton(
onClick = { navController.navigate(SettingsRoutes.API_KEY) },
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
) {
Icon(imageVector = Icons.Default.AutoAwesome, contentDescription = null, modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.text_generate),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Icon(imageVector = Icons.Default.Settings, contentDescription = null, modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.label_configure),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
}
}
}
}
@@ -564,26 +394,16 @@ fun AddManuallyCard(
modifier = modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.padding(24.dp)) {
// Header Row
// Header Row - Using reusable AppIconContainer
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.EditNote,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
}
AppIconContainer(
imageVector = Icons.Default.EditNote
)
Spacer(modifier = Modifier.width(16.dp))
Text(
text = stringResource(R.string.label_add_vocabulary),
@@ -596,37 +416,19 @@ fun AddManuallyCard(
Spacer(modifier = Modifier.height(24.dp))
// Input Fields
TextField(
// Input Fields - Using AppOutlinedTextField
AppOutlinedTextField(
value = wordText,
onValueChange = { wordText = it },
placeholder = { Text(stringResource(R.string.text_label_word), color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)) },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = TextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.surface, // Very dark background
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
),
singleLine = true
placeholder = { Text(stringResource(R.string.text_label_word)) }
)
Spacer(modifier = Modifier.height(16.dp))
TextField(
AppOutlinedTextField(
value = translationText,
onValueChange = { translationText = it },
placeholder = { Text(stringResource(R.string.text_translation), color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)) },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = TextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.surface,
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
),
singleLine = true
placeholder = { Text(stringResource(R.string.text_translation)) }
)
Spacer(modifier = Modifier.height(16.dp))
@@ -661,7 +463,7 @@ fun AddManuallyCard(
Spacer(modifier = Modifier.height(24.dp))
// Add to List Button (Darker variant)
// Add to List Button
AppButton(
onClick = {
val newItem = VocabularyItem(
@@ -690,91 +492,80 @@ fun AddManuallyCard(
}
}
// --- Explore Packs Prominent Card (Full width at top) ---
@Composable
fun BottomActionCardsRow(
modifier: Modifier = Modifier,
onImportCsvClick: () -> Unit
fun ExplorePacksProminentCard(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
AppCard(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp)
onClick = onClick
) {
//TODO Explore Packs Card
AppCard(
Row(
modifier = Modifier
.weight(1f)
.height(120.dp),
.fillMaxWidth()
.padding(20.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.fillMaxSize()
.alpha(0.6f),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = AppIcons.Vocabulary,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
}
Spacer(modifier = Modifier.height(12.dp))
@Suppress("HardCodedStringLiteral")
AppIconContainer(
imageVector = AppIcons.Vocabulary,
size = 56.dp,
iconSize = 28.dp
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Explore Packs",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(6.dp))
@Suppress("HardCodedStringLiteral")
Text(
text = "Coming soon",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
}
}
// Import CSV Card
AppCard(
modifier = Modifier
.weight(1f)
.height(120.dp),
onClick = onImportCsvClick
) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.DriveFolderUpload,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
}
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(R.string.label_import_csv),
style = MaterialTheme.typography.labelLarge,
text = stringResource(R.string.title_explore_packs),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.desc_explore_packs),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
// --- Import CSV Card (Full width at bottom) ---
@Composable
fun ImportCsvCard(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
AppCard(
modifier = modifier.fillMaxWidth(),
onClick = onClick
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
verticalAlignment = Alignment.CenterVertically
) {
AppIconContainer(
imageVector = Icons.Default.DriveFolderUpload,
size = 56.dp,
iconSize = 28.dp
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.label_import_csv_or_lists),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.desc_import_csv),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More