diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b3e548f4..bc581710 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -233,6 +233,13 @@ dependencies { // ========================== implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.1.21") + // ========================== + // API + // ========================== + implementation("com.squareup.retrofit2:retrofit:2.11.0") + implementation("com.squareup.retrofit2:converter-gson:2.11.0") + implementation("com.squareup.okhttp3:logging-interceptor:4.12.0") + // ========================== // Layout and UI // ========================== @@ -316,8 +323,9 @@ dependencies { api("com.google.code.gson:gson:2.13.1") api("com.github.bumptech.glide:glide:4.16.0") ksp("com.github.bumptech.glide:ksp:4.16.0") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2") + implementation("com.charleskorn.kaml:kaml:0.57.0") + implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7") } tasks.register("moveFromi18n") { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b70ae9e9..da738c60 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -9,6 +9,8 @@ + + >>, +) + +/** + * Represents the contract details for the data response, + * including version, update timestamp, and field definitions. + */ +data class Contract( + @SerializedName("version") + val version: String, + @SerializedName("updated_at") + val updatedAt: String, + @SerializedName("fields") + val fields: Map>, +) diff --git a/app/src/main/java/be/scri/data/model/DataVersionResponse.kt b/app/src/main/java/be/scri/data/model/DataVersionResponse.kt new file mode 100644 index 00000000..d7b25243 --- /dev/null +++ b/app/src/main/java/be/scri/data/model/DataVersionResponse.kt @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package be.scri.data.model + +import com.google.gson.annotations.SerializedName + +/** + * Represents the data version response for a specific language. + */ +data class DataVersionResponse( + @SerializedName("language") + val language: String, + @SerializedName("versions") + val versions: Map, +) diff --git a/app/src/main/java/be/scri/data/remote/ApiService.kt b/app/src/main/java/be/scri/data/remote/ApiService.kt new file mode 100644 index 00000000..698fc213 --- /dev/null +++ b/app/src/main/java/be/scri/data/remote/ApiService.kt @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package be.scri.data.remote + +import be.scri.data.model.DataResponse +import be.scri.data.model.DataVersionResponse +import retrofit2.http.GET +import retrofit2.http.Path + +/** + * Defines the API service for fetching data and data version information. + */ +interface ApiService { + @GET("data/{lang}") + suspend fun getData( + @Path("lang") language: String, + ): DataResponse + + @GET("data-version/{lang}") + suspend fun getDataVersion( + @Path("lang") language: String, + ): DataVersionResponse +} diff --git a/app/src/main/java/be/scri/data/remote/DynamicDbHelper.kt b/app/src/main/java/be/scri/data/remote/DynamicDbHelper.kt new file mode 100644 index 00000000..e382ef95 --- /dev/null +++ b/app/src/main/java/be/scri/data/remote/DynamicDbHelper.kt @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package be.scri.data.remote + +import android.content.ContentValues +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteException +import android.database.sqlite.SQLiteOpenHelper +import android.util.Log +import be.scri.data.model.DataResponse + +/** + * Helper class for managing dynamic SQLite databases based on language. + * It creates tables and inserts data according to the provided DataResponse. + */ +class DynamicDbHelper( + context: Context, + language: String, +) : SQLiteOpenHelper(context, "$language.db", null, 1) { + override fun onCreate(db: SQLiteDatabase) { + // Tables are created dynamically via syncDatabase from API contract. + } + + override fun onUpgrade( + db: SQLiteDatabase, + old: Int, + new: Int, + ) { + // Dynamic schema updates are handled via syncDatabase. + } + + /** + * Synchronizes the database schema and data based on the provided DataResponse. + * @param response The data response containing the contract and data to be inserted. + */ + fun syncDatabase(response: DataResponse) { + val db = writableDatabase + + // Create Tables. + response.contract.fields.forEach { (tableName, columns) -> + val colDefinition = columns.keys.joinToString(", ") { "$it TEXT" } + db.execSQL("CREATE TABLE IF NOT EXISTS $tableName (id INTEGER PRIMARY KEY AUTOINCREMENT, $colDefinition)") + db.execSQL("DELETE FROM $tableName") // clear old data + } + + // Insert Data with Transaction. + db.beginTransaction() + try { + response.data.forEach { (tableName, rows) -> + + rows.forEach { row -> + val cv = ContentValues() + row.forEach { (key, value) -> + cv.put(key, value?.toString() ?: "") + } + val result = db.insert(tableName, null, cv) + if (result == -1L) { + Log.e("SCRIBE_DB", "Failed to insert row into $tableName") + } + } + } + db.setTransactionSuccessful() + } catch (e: SQLiteException) { + Log.e("SCRIBE_DB", "Error during insert: ${e.message}") + } finally { + db.endTransaction() + } + } +} diff --git a/app/src/main/java/be/scri/data/remote/RetrofitClient.kt b/app/src/main/java/be/scri/data/remote/RetrofitClient.kt new file mode 100644 index 00000000..9efcdad4 --- /dev/null +++ b/app/src/main/java/be/scri/data/remote/RetrofitClient.kt @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package be.scri.data.remote + +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory + +/** + * Singleton object to provide Retrofit client instance for API calls. + */ +object RetrofitClient { + private const val BASE_URL = "https://scribe-server.toolforge.org/api/v1/" + + val apiService: ApiService by lazy { + val retrofit = + Retrofit + .Builder() + .baseUrl(BASE_URL) + .addConverterFactory(GsonConverterFactory.create()) + .build() + + retrofit.create(ApiService::class.java) + } +} diff --git a/app/src/main/java/be/scri/helpers/data/ConjugateDataManager.kt b/app/src/main/java/be/scri/helpers/data/ConjugateDataManager.kt index d0678fba..e4b93db3 100644 --- a/app/src/main/java/be/scri/helpers/data/ConjugateDataManager.kt +++ b/app/src/main/java/be/scri/helpers/data/ConjugateDataManager.kt @@ -20,7 +20,7 @@ class ConjugateDataManager( * The returned map is structured by tense/mood, then by conjugation type (e.g., "Indicative Present"). * * @param language The language code (e.g., "EN", "SV") to determine the correct database. - * @param jsonData The data contract for the language, which defines the structure of conjugations. + * @param yamlData The data contract for the language, which defines the structure of conjugations. * @param word The specific verb to look up conjugations for. * * @return A nested map where the outer key is the tense group title @@ -29,20 +29,20 @@ class ConjugateDataManager( */ fun getTheConjugateLabels( language: String, - jsonData: DataContract?, + yamlData: DataContract?, word: String, ): MutableMap>>? { val finalOutput: MutableMap>> = mutableMapOf() - jsonData?.conjugations?.values?.forEach { tenseGroup -> + yamlData?.conjugations?.values?.forEach { tenseGroup -> val conjugateForms: MutableMap> = mutableMapOf() - tenseGroup.conjugationTypes.values.forEach { conjugationCategory -> + tenseGroup.tenses.values.forEach { conjugationCategory -> val forms = - conjugationCategory.conjugationForms.values.map { form -> - getTheValueForTheConjugateWord(word.lowercase(), form, language) + conjugationCategory.tenseForms.values.map { form -> + getTheValueForTheConjugateWord(word.lowercase(), form.value, language) } - conjugateForms[conjugationCategory.title] = forms + conjugateForms[conjugationCategory.tenseTitle] = forms } - finalOutput[tenseGroup.title] = conjugateForms + finalOutput[tenseGroup.sectionTitle] = conjugateForms } return if (finalOutput.isEmpty() || finalOutput.values.all { it.isEmpty() || it.values.all { forms -> forms.all { it.isEmpty() } } }) { null @@ -55,19 +55,21 @@ class ConjugateDataManager( * Extracts a unique set of all conjugation form keys (e.g., "1ps", "2ps", "participle") * from the data contract. * - * @param jsonData The data contract containing the conjugation structure. + * @param yamlData The data contract containing the conjugation structure. * @param word The base word, which is also added to the set. * * @return A `Set` of unique strings representing all possible conjugation form identifiers. */ fun extractConjugateHeadings( - jsonData: DataContract?, + yamlData: DataContract?, word: String, ): Set { val allFormKeys = mutableSetOf() - jsonData?.conjugations?.values?.forEach { tenseGroup -> - tenseGroup.conjugationTypes.values.forEach { conjugationCategory -> - allFormKeys.addAll(conjugationCategory.conjugationForms.keys) + yamlData?.conjugations?.values?.forEach { tenseGroup -> + tenseGroup.tenses.values.forEach { conjugationCategory -> + conjugationCategory.tenseForms.values.forEach { form -> + allFormKeys.add(form.label) + } } } allFormKeys.add(word) diff --git a/app/src/main/java/be/scri/helpers/data/ContractDataLoader.kt b/app/src/main/java/be/scri/helpers/data/ContractDataLoader.kt index 085abc8b..9f6e3886 100644 --- a/app/src/main/java/be/scri/helpers/data/ContractDataLoader.kt +++ b/app/src/main/java/be/scri/helpers/data/ContractDataLoader.kt @@ -4,8 +4,9 @@ package be.scri.helpers.data import DataContract import android.content.Context import android.util.Log -import kotlinx.serialization.SerializationException -import kotlinx.serialization.json.Json +import com.charleskorn.kaml.Yaml +import com.charleskorn.kaml.YamlConfiguration +import com.charleskorn.kaml.YamlException import java.io.IOException /** @@ -16,29 +17,32 @@ class ContractDataLoader( private val context: Context, ) { /** - * Loads and deserializes a data contract from a JSON file in the assets folder. - * It gracefully handles file-not-found and JSON parsing errors by returning null. + * Loads and deserializes a data contract from a YAML file in the assets folder. + * It gracefully handles file-not-found and YAML parsing errors by returning null. * - * @param language The language code (e.g., "DE", "EN") used to determine the filename (e.g., "de.json"). + * @param language The language code (e.g., "DE", "EN") used to determine the filename (e.g., "de.yaml"). * * @return The decoded [DataContract] object if successful, or `null` * if the file does not exist or cannot be parsed. */ fun loadContract(language: String): DataContract? { - val contractName = "${language.lowercase()}.json" + val contractName = "${language.lowercase()}.yaml" Log.i("ContractDataLoader", "Attempting to load contract: $contractName") return try { - val jsonParser = Json { ignoreUnknownKeys = true } context.assets.open("data-contracts/$contractName").use { contractFile -> val content = contractFile.bufferedReader().readText() - jsonParser.decodeFromString(content) + val yaml = + Yaml( + configuration = YamlConfiguration(strictMode = false), + ) + yaml.decodeFromString(DataContract.serializer(), content) } } catch (e: IOException) { Log.e("ContractDataLoader", "Error loading contract file: $contractName. It may not exist.", e) null - } catch (e: SerializationException) { - Log.e("ContractDataLoader", "Error parsing JSON for contract: $contractName", e) + } catch (e: YamlException) { + Log.e("ContractDataLoader", "Error parsing YAML for contract: $contractName", e) null } } diff --git a/app/src/main/java/be/scri/helpers/data/PluralFormsManager.kt b/app/src/main/java/be/scri/helpers/data/PluralFormsManager.kt index eb1c4d0a..0d4b84f2 100644 --- a/app/src/main/java/be/scri/helpers/data/PluralFormsManager.kt +++ b/app/src/main/java/be/scri/helpers/data/PluralFormsManager.kt @@ -17,16 +17,16 @@ class PluralFormsManager( * Retrieves a list of all known plural forms for a given language from the database. * * @param language The language code (e.g., "EN", "DE") to select the correct database. - * @param jsonData The data contract, which specifies the names of the columns containing plural forms. + * @param yamlData The data contract, which specifies the names of the columns containing plural forms. * * @return A [List] of all plural word forms, or `null` * if the operation fails or no plural columns are defined. */ fun getAllPluralForms( language: String, - jsonData: DataContract?, + yamlData: DataContract?, ): List? = - jsonData?.numbers?.values?.toList()?.takeIf { it.isNotEmpty() }?.let { pluralForms -> + yamlData?.numbers?.values?.toList()?.takeIf { it.isNotEmpty() }?.let { pluralForms -> fileManager.getLanguageDatabase(language)?.use { db -> queryAllPluralForms(db, pluralForms) } @@ -36,7 +36,7 @@ class PluralFormsManager( * Retrieves the specific plural representation for a single noun. * * @param language The language code to select the correct database. - * @param jsonData The data contract, which specifies the singular and plural column names. + * @param yamlData The data contract, which specifies the singular and plural column names. * @param noun The singular noun to find the plural for. * * @return A [Map] containing the singular noun as the key and @@ -44,10 +44,10 @@ class PluralFormsManager( */ fun getPluralRepresentation( language: String, - jsonData: DataContract?, + yamlData: DataContract?, noun: String, ): Map = - jsonData?.numbers?.let { numbers -> + yamlData?.numbers?.let { numbers -> val singularCol = numbers.keys.firstOrNull() val pluralCol = numbers.values.firstOrNull() diff --git a/app/src/main/java/be/scri/models/DataContract.kt b/app/src/main/java/be/scri/models/DataContract.kt index 6a4015d8..250cecf6 100644 --- a/app/src/main/java/be/scri/models/DataContract.kt +++ b/app/src/main/java/be/scri/models/DataContract.kt @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later @file:Suppress("ktlint:standard:kdoc") +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable /** @@ -13,7 +14,8 @@ import kotlinx.serialization.Serializable data class DataContract( val numbers: Map, val genders: Genders, - val conjugations: Map, + val conjugations: Map, + val translations: Translations, ) /** @@ -33,8 +35,8 @@ data class Genders( */ @Serializable data class TenseGroup( - val title: String = "", - val conjugationTypes: Map, + val sectionTitle: String = "", + val tenses: Map, ) /** @@ -42,6 +44,47 @@ data class TenseGroup( */ @Serializable data class ConjugationCategory( - val title: String = "", - val conjugationForms: Map = emptyMap(), + val tenseTitle: String = "", + val tenseForms: Map, +) + +@Serializable +data class TenseForm( + val label: String, + val value: String, +) + +/** + * Represents the structure of translations for different word types. + */ +@Serializable +data class Translations( + val wordType: WordType, +) + +/** + * Represents the various parts of speech and their associated display values and section titles. + */ +@Serializable +data class WordType( + val sectionTitle: String, + val adjective: WordTypeEntry, + val adverb: WordTypeEntry, + val article: WordTypeEntry, + val conjunction: WordTypeEntry, + val noun: WordTypeEntry, + val postposition: WordTypeEntry, + val preposition: WordTypeEntry, + @SerialName("proper_noun") val properNoun: WordTypeEntry, + val pronoun: WordTypeEntry, + val verb: WordTypeEntry, +) + +/** + * Represents the display value and section title for a specific part of speech. + */ +@Serializable +data class WordTypeEntry( + val displayValue: String, + val sectionTitle: String, ) diff --git a/app/src/main/java/be/scri/ui/common/appcomponents/InstallKeyboardButton.kt b/app/src/main/java/be/scri/ui/common/appcomponents/InstallKeyboardButton.kt new file mode 100644 index 00000000..1a240cf3 --- /dev/null +++ b/app/src/main/java/be/scri/ui/common/appcomponents/InstallKeyboardButton.kt @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package be.scri.ui.common.appcomponents + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import be.scri.R + +/** + * A composable button that prompts the user to install keyboards. + * + * @param onClick The callback to invoke when the button is clicked. + * @param modifier Optional [Modifier] for styling and layout adjustments. + */ +@Composable +fun InstallKeyboardButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + OutlinedButton( + onClick = onClick, + modifier = + modifier + .fillMaxWidth() + .padding(Dimensions.PaddingSmallXL) + .shadow(Dimensions.ElevationSmall, RoundedCornerShape(dimensionResource(id = R.dimen.rounded_corner_radius_standard))), + shape = RoundedCornerShape(dimensionResource(id = R.dimen.rounded_corner_radius_standard)), + colors = + ButtonDefaults.outlinedButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Text( + text = stringResource(R.string.i18n_app_settings_button_install_keyboards), + fontSize = Dimensions.TextSizeExtraLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.padding(vertical = Dimensions.PaddingLarge), + ) + } +} + +/** + * Defines commonly used dimensions for the Settings screen UI. + * Includes padding, text sizes, and elevation values. + */ +object Dimensions { + val PaddingLarge = 20.dp + val PaddingSmallXL = 12.dp + + val TextSizeExtraLarge = 24.sp + + val ElevationSmall = 4.dp +} diff --git a/app/src/main/java/be/scri/ui/screens/SelectLanguageScreen.kt b/app/src/main/java/be/scri/ui/screens/SelectLanguageScreen.kt index 7446a804..fad200aa 100644 --- a/app/src/main/java/be/scri/ui/screens/SelectLanguageScreen.kt +++ b/app/src/main/java/be/scri/ui/screens/SelectLanguageScreen.kt @@ -46,7 +46,7 @@ fun SelectTranslationSourceLanguageScreen( onBackNavigation: () -> Unit, onNavigateToDownloadData: () -> Unit, modifier: Modifier = Modifier, - onDownloadAction: (String) -> Unit = {}, + onDownloadAction: (String, Boolean) -> Unit = { _, _ -> }, ) { val context = LocalContext.current val sharedPref = context.getSharedPreferences("app_preferences", Context.MODE_PRIVATE) @@ -143,7 +143,7 @@ fun SelectTranslationSourceLanguageScreen( val downloadKey = currentLanguage.lowercase() // trigger the download action in the ViewModel. - onDownloadAction(downloadKey) + onDownloadAction(downloadKey, true) showDialog.value = false // Navigate to the download data screen. onNavigateToDownloadData() diff --git a/app/src/main/java/be/scri/ui/screens/download/DataDownloadScreen.kt b/app/src/main/java/be/scri/ui/screens/download/DataDownloadScreen.kt index e3a21942..386625f3 100644 --- a/app/src/main/java/be/scri/ui/screens/download/DataDownloadScreen.kt +++ b/app/src/main/java/be/scri/ui/screens/download/DataDownloadScreen.kt @@ -18,8 +18,15 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.Immutable +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.rememberUpdatedState +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext @@ -27,14 +34,23 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.viewmodel.compose.viewModel import be.scri.R import be.scri.helpers.StringUtils import be.scri.ui.common.ScribeBaseScreen import be.scri.ui.common.appcomponents.ConfirmationDialog +import be.scri.ui.common.appcomponents.InstallKeyboardButton import be.scri.ui.common.components.CircleClickableItemComp import be.scri.ui.common.components.LanguageItemComp import be.scri.ui.common.components.SwitchableItemComp import be.scri.ui.screens.settings.SettingsUtil +import be.scri.ui.screens.settings.SettingsViewModel +import be.scri.ui.screens.settings.SettingsViewModelFactory +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList /** * Screen for downloading and managing language data. @@ -44,31 +60,50 @@ import be.scri.ui.screens.settings.SettingsUtil * @param modifier Modifier for layout and styling. * @param downloadStates Map of language keys to their download states. * @param onDownloadAction Callback for download action when a language is selected and confirmed. + * @param initializeStates Callback to initialize download states for given languages. + * @param checkAllForUpdates Callback to check all languages for available updates. */ @Composable fun DownloadDataScreen( onBackNavigation: () -> Unit, onNavigateToTranslation: (String) -> Unit, + checkAllForUpdates: () -> Unit, modifier: Modifier = Modifier, downloadStates: Map = emptyMap(), - onDownloadAction: (String) -> Unit = {}, + onDownloadAction: (String, Boolean) -> Unit = { _, _ -> }, + onDownloadAll: () -> Unit = {}, + initializeStates: (List) -> Unit = {}, + viewModel: SettingsViewModel = + viewModel( + factory = SettingsViewModelFactory(LocalContext.current), + ), ) { + val currentInitializeStates by rememberUpdatedState(initializeStates) val scrollState = rememberScrollState() val checkForNewData = remember { mutableStateOf(false) } val regularlyUpdateData = remember { mutableStateOf(true) } - val selectedLanguage = remember { mutableStateOf?>(null) } + val selectedLanguage = remember { mutableStateOf(null) } val context = LocalContext.current val sharedPref = context.getSharedPreferences("app_preferences", Context.MODE_PRIVATE) - val installedKeyboardLanguages = - remember { - SettingsUtil.getKeyboardLanguages(context) - } + val installedKeyboardLanguages by viewModel.languages.collectAsState() + + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + viewModel.refreshSettings(context) + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } - val languages = + // Prepare the list of languages to display, including the "All Languages" option. + val languages: ImmutableList = remember(installedKeyboardLanguages) { buildList { - add(Triple("all", context.getString(R.string.i18n_app_download_menu_ui_download_data_all_languages), false)) - + add(LanguageItem("all", context.getString(R.string.i18n_app_download_menu_ui_download_data_all_languages), false)) installedKeyboardLanguages.forEach { languageCode -> val displayName = when (languageCode.lowercase()) { @@ -82,13 +117,25 @@ fun DownloadDataScreen( "swedish" -> context.getString(R.string.i18n_app__global_swedish) else -> languageCode.replaceFirstChar { it.uppercase() } } - - val key = languageCode.lowercase() - add(Triple(key, displayName, false)) + add(LanguageItem(languageCode.lowercase(), displayName, false)) } - } + }.toImmutableList() } + // Determine the state of the "All Languages" item based on individual language states. + val allLanguagesState = + when { + downloadStates.filter { it.key != "all" }.values.all { it == DownloadState.Completed } -> DownloadState.Completed + downloadStates.filter { it.key != "all" }.values.all { it == DownloadState.Downloading } -> DownloadState.Downloading + downloadStates.filter { it.key != "all" }.values.all { it == DownloadState.Update } -> DownloadState.Update + else -> DownloadState.Ready + } + + LaunchedEffect(languages) { + val keys = languages.map { it.key } + currentInitializeStates(keys) + } + ScribeBaseScreen( pageTitle = stringResource(R.string.i18n_app__global_download_data), lastPage = stringResource(R.string.i18n_app_installation_title), @@ -102,6 +149,7 @@ fun DownloadDataScreen( .verticalScroll(scrollState), verticalArrangement = Arrangement.spacedBy(12.dp), ) { + // Update Data Section Column { Text( text = stringResource(R.string.i18n_app_download_menu_ui_update_data), @@ -119,18 +167,21 @@ fun DownloadDataScreen( color = MaterialTheme.colorScheme.surface, ) { Column(Modifier.padding(vertical = 10.dp, horizontal = 4.dp)) { - CircleClickableItemComp( - title = stringResource(R.string.i18n_app_download_menu_ui_update_data_check_new), - onClick = { checkForNewData.value = !checkForNewData.value }, - isSelected = checkForNewData.value, - ) - - HorizontalDivider( - color = Color.Gray.copy(alpha = 0.3f), - thickness = 1.dp, - modifier = Modifier.padding(vertical = 8.dp, horizontal = 12.dp), - ) - + if (installedKeyboardLanguages.isNotEmpty()) { + CircleClickableItemComp( + title = stringResource(R.string.i18n_app_download_menu_ui_update_data_check_new), + onClick = { + checkForNewData.value = !checkForNewData.value + if (checkForNewData.value) checkAllForUpdates() + }, + isSelected = checkForNewData.value, + ) + HorizontalDivider( + color = Color.Gray.copy(alpha = 0.3f), + thickness = 1.dp, + modifier = Modifier.padding(vertical = 8.dp, horizontal = 12.dp), + ) + } SwitchableItemComp( title = stringResource(R.string.i18n_app_download_menu_ui_update_data_regular_update), isChecked = regularlyUpdateData.value, @@ -139,6 +190,8 @@ fun DownloadDataScreen( } } } + + // Download Data Section Column { Text( text = stringResource(R.string.i18n_app_download_menu_ui_download_data_title), @@ -148,70 +201,140 @@ fun DownloadDataScreen( modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 10.dp), ) - Surface( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp), - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surface, - ) { - Column(Modifier.padding(vertical = 10.dp, horizontal = 4.dp)) { - languages.forEachIndexed { index, lang -> - val (key, title, isDark) = lang - val currentStatus = downloadStates[key] ?: DownloadState.Ready - - LanguageItemComp( - title = title, - onClick = { }, - onButtonClick = { - if (currentStatus == DownloadState.Ready) { - selectedLanguage.value = lang - } else { - onDownloadAction(key) - } - }, - isDarkTheme = isDark, - buttonState = currentStatus, - ) - if (index < languages.lastIndex) { - HorizontalDivider( - color = Color.Gray.copy(alpha = 0.3f), - thickness = 1.dp, - modifier = Modifier.padding(vertical = 8.dp, horizontal = 12.dp), - ) - } - } - } + if (installedKeyboardLanguages.isEmpty()) { + EmptyStateSection(context) + } else { + LanguagesListSection( + languages = languages, + allLanguagesState = allLanguagesState, + downloadStates = downloadStates, + onLanguageSelect = { selectedLanguage.value = it }, + onDownloadAll = onDownloadAll, + onDownloadAction = onDownloadAction, + ) + } + + Spacer(modifier = Modifier.height(10.dp)) + + selectedLanguage.value?.let { lang -> + val (key, title, _) = lang + val languageId = key.replaceFirstChar { it.uppercase() } + val sourceLang = sharedPref.getString("translation_source_$languageId", "English") ?: "English" + ConfirmationDialog( + text = + StringUtils.stringResourceWithParams( + R.string.i18n_app_download_menu_ui_translation_source_tooltip_download_warning, + sourceLang, + title, + ), + textConfirm = + StringUtils.stringResourceWithParams( + R.string.i18n_app_download_menu_ui_translation_source_tooltip_use_source_language, + sourceLang, + ), + textChange = stringResource(R.string.i18n_app_download_menu_ui_translation_source_tooltip_change_language), + onConfirm = { + onDownloadAction(key, false) + selectedLanguage.value = null + }, + onChange = { onNavigateToTranslation(languageId) }, + onDismiss = { selectedLanguage.value = null }, + ) } } + } + } +} + +@Immutable +data class LanguageItem( + val key: String, + val displayName: String, + val isDark: Boolean, +) + +/** + * Represents empty state when no languages are available for download. + */ +@Composable +private fun EmptyStateSection(context: Context) { + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.i18n_app_download_menu_ui_no_keyboards_installed), + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), + fontSize = 16.sp, + ) + } + } + InstallKeyboardButton( + onClick = { SettingsUtil.navigateToKeyboardSettings(context) }, + ) +} - Spacer(modifier = Modifier.height(10.dp)) - - selectedLanguage.value?.let { lang -> - val (key, title, _) = lang - val languageId = key.replaceFirstChar { it.uppercase() } - val sourceLang = sharedPref.getString("translation_source_$languageId", "English") ?: "English" - ConfirmationDialog( - text = - StringUtils.stringResourceWithParams( - R.string.i18n_app_download_menu_ui_translation_source_tooltip_download_warning, - sourceLang, - title, - ), - textConfirm = - StringUtils.stringResourceWithParams( - R.string.i18n_app_download_menu_ui_translation_source_tooltip_use_source_language, - sourceLang, - ), - textChange = stringResource(R.string.i18n_app_download_menu_ui_translation_source_tooltip_change_language), - onConfirm = { - onDownloadAction(key) - selectedLanguage.value = null +/** + * Composable function to display the list of languages available for download, along with their respective download states and actions. + * + * @param languages List of [LanguageItem] representing the available languages. + * @param allLanguagesState The overall download state for all languages. + * @param downloadStates Map of individual language keys to their respective [DownloadState]. + * @param onLanguageSelect Callback invoked when a specific language is selected for download. + * @param onDownloadAll Callback invoked when the "All Languages" option is selected for download. + * @param onDownloadAction Callback invoked when a specific language's download action is triggered, with parameters for language key and whether it's an "all" action. + */ +@Composable +private fun LanguagesListSection( + languages: ImmutableList, + allLanguagesState: DownloadState, + downloadStates: Map, + onLanguageSelect: (LanguageItem) -> Unit, + onDownloadAll: () -> Unit, + onDownloadAction: (String, Boolean) -> Unit, +) { + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Column(Modifier.padding(vertical = 10.dp, horizontal = 4.dp)) { + languages.forEachIndexed { index, lang -> + val currentStatus = if (lang.key == "all") allLanguagesState else (downloadStates[lang.key] ?: DownloadState.Ready) + + LanguageItemComp( + title = lang.displayName, + onClick = { }, + onButtonClick = { + if (lang.key == "all") { + onDownloadAll() + } else if (currentStatus == DownloadState.Ready) { + onLanguageSelect(lang) + } else { + onDownloadAction(lang.key, false) + } }, - onChange = { onNavigateToTranslation(languageId) }, - onDismiss = { selectedLanguage.value = null }, + isDarkTheme = lang.isDark, + buttonState = currentStatus, ) + if (index < languages.lastIndex) { + HorizontalDivider( + color = Color.Gray.copy(alpha = 0.3f), + thickness = 1.dp, + modifier = Modifier.padding(vertical = 8.dp, horizontal = 12.dp), + ) + } } } } diff --git a/app/src/main/java/be/scri/ui/screens/download/DataDownloadViewModel.kt b/app/src/main/java/be/scri/ui/screens/download/DataDownloadViewModel.kt index e3230e5d..7d2255bb 100644 --- a/app/src/main/java/be/scri/ui/screens/download/DataDownloadViewModel.kt +++ b/app/src/main/java/be/scri/ui/screens/download/DataDownloadViewModel.kt @@ -2,26 +2,76 @@ package be.scri.ui.screens.download +import android.app.Application +import android.content.Context +import android.database.sqlite.SQLiteException +import android.util.Log +import android.widget.Toast import androidx.compose.runtime.mutableStateMapOf -import androidx.lifecycle.ViewModel +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import be.scri.data.remote.DynamicDbHelper +import be.scri.data.remote.RetrofitClient +import be.scri.helpers.LanguageMappingConstants +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import retrofit2.HttpException +import java.io.IOException import java.time.LocalDate -private const val PLACEBO_SERVER_UPDATED_AT = "2025-01-10" -private const val PLACEBO_LOCAL_UPDATED_AT = "2025-01-01" - /** ViewModel to manage data download states and actions. */ -class DataDownloadViewModel : ViewModel() { +class DataDownloadViewModel( + application: Application, +) : AndroidViewModel(application) { val downloadStates = mutableStateMapOf() + private val downloadJobs = mutableMapOf() + private val prefs = getApplication().getSharedPreferences("scribe_prefs", Context.MODE_PRIVATE) /** - * @return true if server data is newer than local data. + * Initializes the download states for the provided languages. + * + * @param languages A list of language keys to initialize states for. + */ + fun initializeStates(languages: List) { + languages.forEach { key -> + if (key == "all") return@forEach + if (downloadStates.containsKey(key)) return@forEach + + val langCode = + LanguageMappingConstants + .getLanguageAlias( + key.replaceFirstChar { it.uppercase() }, + ).lowercase() + + // Check if a timestamp exists in SharedPreferences. + val savedTimestamp = prefs.getString("last_update_$langCode", null) + + if (savedTimestamp != null) { + downloadStates[key] = DownloadState.Completed + } else { + downloadStates[key] = DownloadState.Ready + } + } + + // After initializing, check for updates on all Completed languages. + checkAllForUpdates() + } + + /** + * Checks if an update is available by comparing local and server update timestamps. + * + * @param localUpdatedAt The last update timestamp stored locally. + * @param serverUpdatedAt The last update timestamp from the server. + * @return True if an update is available, false otherwise. */ private fun isUpdateAvailable( localUpdatedAt: String, serverUpdatedAt: String, ): Boolean { - val localDate = LocalDate.parse(localUpdatedAt) - val serverDate = LocalDate.parse(serverUpdatedAt) + val localDate = LocalDate.parse(localUpdatedAt.take(10)) + val serverDate = LocalDate.parse(serverUpdatedAt.take(10)) return serverDate.isAfter(localDate) } @@ -30,21 +80,166 @@ class DataDownloadViewModel : ViewModel() { * Handles the download action based on the current state. * * @param key The key identifying the download item. + * @param forceDownload If true, cancels any existing download and forces a new one. */ - fun handleDownloadAction(key: String) { + fun handleDownloadAction( + key: String, + forceDownload: Boolean = false, + ) { val currentState = downloadStates[key] ?: DownloadState.Ready - downloadStates[key] = - when (currentState) { - DownloadState.Ready -> DownloadState.Downloading - DownloadState.Downloading -> DownloadState.Completed - DownloadState.Completed -> - if (isUpdateAvailable(PLACEBO_LOCAL_UPDATED_AT, PLACEBO_SERVER_UPDATED_AT)) { - DownloadState.Update + val displayLang = key.replaceFirstChar { it.uppercase() } + if (forceDownload) { + downloadJobs[key]?.cancel() + } else { + if (currentState == DownloadState.Downloading) { + return + } + + if (currentState == DownloadState.Completed) { + Toast.makeText(getApplication(), "$displayLang data is already up to date", Toast.LENGTH_SHORT).show() + return + } + } + + // Set to downloading before hitting the network. + downloadStates[key] = DownloadState.Downloading + + val langCode = + LanguageMappingConstants + .getLanguageAlias( + key.replaceFirstChar { it.uppercase() }, + ).lowercase() + + val localLastUpdate = prefs.getString("last_update_$langCode", "1970-01-01") ?: "1970-01-01" + + // Store the job so we can cancel it later if needed. + downloadJobs[key] = + viewModelScope.launch(Dispatchers.IO) { + try { + // Fetch API. + val response = RetrofitClient.apiService.getData(langCode) + val serverLastUpdate = response.contract.updatedAt + + // Always download when forcing, or when update is available. + if (forceDownload || isUpdateAvailable(localLastUpdate, serverLastUpdate)) { + val dbHelper = DynamicDbHelper(getApplication(), langCode) + dbHelper.syncDatabase(response) + + // Save timestamp. + prefs.edit().putString("last_update_$langCode", serverLastUpdate).apply() + + withContext(Dispatchers.Main) { + downloadStates[key] = DownloadState.Completed + Toast.makeText(getApplication(), "Download $displayLang data finished!", Toast.LENGTH_SHORT).show() + } } else { - DownloadState.Completed + // Already up to date: Skip the DB work. + withContext(Dispatchers.Main) { + downloadStates[key] = DownloadState.Completed + Toast.makeText(getApplication(), "Already up to date!", Toast.LENGTH_SHORT).show() + } + } + } catch (e: IOException) { + updateErrorState(key, "Network Error: ${e.message}") + } catch (e: SQLiteException) { + updateErrorState(key, "Database Error: ${e.message}") + } catch (e: HttpException) { + updateErrorState(key, "Server Error: ${e.code()}") + } finally { + // Clean up the job reference when done. + downloadJobs.remove(key) + } + } + } + + /** + * Handles the "All languages" download action by initiating downloads for all languages that are not already completed or downloading. + */ + fun handleDownloadAllLanguages() { + val toDownload = + downloadStates.keys.filter { key -> + key != "all" && downloadStates[key] != DownloadState.Completed && downloadStates[key] != DownloadState.Downloading + } + toDownload.forEach { key -> + handleDownloadAction(key) + } + } + + /** + * Checks for available updates using the data version API. + * Sets state to Update if server has newer data. + * + * @param key The key identifying the download item. + */ + fun checkForUpdates(key: String) { + val currentState = downloadStates[key] ?: DownloadState.Ready + if (currentState == DownloadState.Downloading) return + + val langCode = + LanguageMappingConstants + .getLanguageAlias(key.replaceFirstChar { it.uppercase() }) + .lowercase() + + val localLastUpdate = prefs.getString("last_update_$langCode", "1970-01-01") ?: "1970-01-01" + + viewModelScope.launch(Dispatchers.IO) { + try { + val response = RetrofitClient.apiService.getDataVersion(langCode) + + val hasUpdate = + response.versions.values.any { serverDate -> + isUpdateAvailable(localLastUpdate, serverDate) } - DownloadState.Update -> DownloadState.Downloading + + withContext(Dispatchers.Main) { + downloadStates[key] = + if (hasUpdate) { + DownloadState.Update + } else { + DownloadState.Completed + } + } + } catch (e: IOException) { + Log.w("DownloadVM", "Network error while checking updates for $key: ${e.message}") + } catch (e: HttpException) { + Log.w("DownloadVM", "Server error while checking updates for $key: ${e.code()}") + } catch (e: SQLiteException) { + Log.w("DownloadVM", "Database error while checking updates for $key: ${e.message}") + } + } + } + + /** + * Checks all languages for updates. + */ + fun checkAllForUpdates() { + downloadStates.keys.forEach { key -> + if (key == "all") return@forEach + // Only check languages that have been downloaded before. + if (downloadStates[key] == DownloadState.Completed) { + checkForUpdates(key) } + } + } + + private suspend fun updateErrorState( + key: String, + message: String, + ) { + withContext(Dispatchers.Main) { + // Reset status so user can retry. + downloadStates[key] = DownloadState.Ready + Toast.makeText(getApplication(), message, Toast.LENGTH_LONG).show() + } + } + + /** + * Cancels all ongoing downloads. + */ + override fun onCleared() { + super.onCleared() + downloadJobs.values.forEach { it.cancel() } + downloadJobs.clear() } } diff --git a/app/src/main/java/be/scri/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/be/scri/ui/screens/settings/SettingsScreen.kt index a7fc6c4a..ff53ca5d 100644 --- a/app/src/main/java/be/scri/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/be/scri/ui/screens/settings/SettingsScreen.kt @@ -8,29 +8,21 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.shadow import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.viewmodel.compose.viewModel import be.scri.R import be.scri.ui.common.ScribeBaseScreen +import be.scri.ui.common.appcomponents.InstallKeyboardButton import be.scri.ui.common.components.ItemCardContainerWithTitle import be.scri.ui.models.ScribeItem import be.scri.ui.models.ScribeItemList @@ -139,41 +131,3 @@ fun SettingsScreen( } } } - -@Composable -private fun InstallKeyboardButton(onClick: () -> Unit) { - OutlinedButton( - onClick = onClick, - modifier = - Modifier - .fillMaxWidth() - .padding(Dimensions.PaddingSmallXL) - .shadow(Dimensions.ElevationSmall, RoundedCornerShape(dimensionResource(id = R.dimen.rounded_corner_radius_standard))), - shape = RoundedCornerShape(dimensionResource(id = R.dimen.rounded_corner_radius_standard)), - colors = - ButtonDefaults.outlinedButtonColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - ) { - Text( - text = stringResource(R.string.i18n_app_settings_button_install_keyboards), - fontSize = Dimensions.TextSizeExtraLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier.padding(vertical = Dimensions.PaddingLarge), - ) - } -} - -/** - * Defines commonly used dimensions for the Settings screen UI. - * Includes padding, text sizes, and elevation values. - */ -object Dimensions { - val PaddingLarge = 20.dp - val PaddingSmallXL = 12.dp - - val TextSizeExtraLarge = 24.sp - - val ElevationSmall = 4.dp -} diff --git a/app/src/main/java/be/scri/ui/screens/settings/SettingsUtil.kt b/app/src/main/java/be/scri/ui/screens/settings/SettingsUtil.kt index 16258974..b47eae15 100644 --- a/app/src/main/java/be/scri/ui/screens/settings/SettingsUtil.kt +++ b/app/src/main/java/be/scri/ui/screens/settings/SettingsUtil.kt @@ -26,7 +26,7 @@ object SettingsUtil { fun checkKeyboardInstallation(context: Context): Boolean { val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - return imm.enabledInputMethodList.any { it.packageName == "be.scri.debug" } + return imm.enabledInputMethodList.any { it.packageName == context.packageName } } /**