diff --git a/app/src/main/assets/data/DELanguageData.sqlite b/app/src/main/assets/data/DELanguageData.sqlite deleted file mode 100644 index 7934ed73..00000000 Binary files a/app/src/main/assets/data/DELanguageData.sqlite and /dev/null differ diff --git a/app/src/main/assets/data/ENLanguageData.sqlite b/app/src/main/assets/data/ENLanguageData.sqlite deleted file mode 100644 index fe268aa8..00000000 Binary files a/app/src/main/assets/data/ENLanguageData.sqlite and /dev/null differ diff --git a/app/src/main/assets/data/ESLanguageData.sqlite b/app/src/main/assets/data/ESLanguageData.sqlite deleted file mode 100644 index 81612e1e..00000000 Binary files a/app/src/main/assets/data/ESLanguageData.sqlite and /dev/null differ diff --git a/app/src/main/assets/data/FRLanguageData.sqlite b/app/src/main/assets/data/FRLanguageData.sqlite deleted file mode 100644 index 31c11f31..00000000 Binary files a/app/src/main/assets/data/FRLanguageData.sqlite and /dev/null differ diff --git a/app/src/main/assets/data/ITLanguageData.sqlite b/app/src/main/assets/data/ITLanguageData.sqlite deleted file mode 100644 index ca3f2832..00000000 Binary files a/app/src/main/assets/data/ITLanguageData.sqlite and /dev/null differ diff --git a/app/src/main/assets/data/PTLanguageData.sqlite b/app/src/main/assets/data/PTLanguageData.sqlite deleted file mode 100644 index 7e30543e..00000000 Binary files a/app/src/main/assets/data/PTLanguageData.sqlite and /dev/null differ diff --git a/app/src/main/assets/data/RULanguageData.sqlite b/app/src/main/assets/data/RULanguageData.sqlite deleted file mode 100644 index 6dffd162..00000000 Binary files a/app/src/main/assets/data/RULanguageData.sqlite and /dev/null differ diff --git a/app/src/main/assets/data/SVLanguageData.sqlite b/app/src/main/assets/data/SVLanguageData.sqlite deleted file mode 100644 index 393cce0b..00000000 Binary files a/app/src/main/assets/data/SVLanguageData.sqlite and /dev/null differ diff --git a/app/src/main/java/be/scri/App.kt b/app/src/main/java/be/scri/App.kt index 982cce74..29a2aa99 100644 --- a/app/src/main/java/be/scri/App.kt +++ b/app/src/main/java/be/scri/App.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier @@ -38,7 +39,9 @@ import be.scri.ui.screens.SelectTranslationSourceLanguageScreen import be.scri.ui.screens.ThirdPartyScreen import be.scri.ui.screens.WikimediaScreen import be.scri.ui.screens.about.AboutScreen +import be.scri.ui.screens.download.CheckUpdateActions import be.scri.ui.screens.download.DataDownloadViewModel +import be.scri.ui.screens.download.DownloadActions import be.scri.ui.screens.download.DownloadDataScreen import be.scri.ui.screens.settings.SettingsScreen import be.scri.ui.theme.ScribeTheme @@ -82,8 +85,21 @@ fun ScribeApp( val downloadStates = downloadViewModel.downloadStates val onDownloadAction = downloadViewModel::handleDownloadAction val onDownloadAll = downloadViewModel::handleDownloadAllLanguages - val inititalizeStates = downloadViewModel::initializeStates - val checkAllForUpdates = downloadViewModel::checkAllForUpdates + val initializeStates = downloadViewModel::initializeStates + val downloadActions = + DownloadActions( + downloadStates = downloadStates, + onDownloadAction = onDownloadAction, + onDownloadAll = onDownloadAll, + initializeStates = initializeStates, + ) + val checkUpdateState by downloadViewModel.checkUpdateState.collectAsState() + val checkUpdateActions = + CheckUpdateActions( + checkUpdateState = checkUpdateState, + checkForNewData = downloadViewModel::checkForNewData, + cancelCheckForNewData = downloadViewModel::cancelCheckForNewData, + ) ScribeTheme( useDarkTheme = isDarkTheme, @@ -213,11 +229,9 @@ fun ScribeApp( "translation_language_detail/$language", ) }, - downloadStates = downloadStates, - onDownloadAction = onDownloadAction, - onDownloadAll = onDownloadAll, - initializeStates = inititalizeStates, - checkAllForUpdates = checkAllForUpdates, + isDarkTheme = isDarkTheme, + downloadActions = downloadActions, + checkUpdateActions = checkUpdateActions, modifier = Modifier.padding(innerPadding), ) } diff --git a/app/src/main/java/be/scri/data/remote/DynamicDbHelper.kt b/app/src/main/java/be/scri/data/remote/DynamicDbHelper.kt index e382ef95..56c57bed 100644 --- a/app/src/main/java/be/scri/data/remote/DynamicDbHelper.kt +++ b/app/src/main/java/be/scri/data/remote/DynamicDbHelper.kt @@ -17,7 +17,7 @@ import be.scri.data.model.DataResponse class DynamicDbHelper( context: Context, language: String, -) : SQLiteOpenHelper(context, "$language.db", null, 1) { +) : SQLiteOpenHelper(context, "${language}LanguageData.sqlite", null, 1) { override fun onCreate(db: SQLiteDatabase) { // Tables are created dynamically via syncDatabase from API contract. } @@ -36,28 +36,26 @@ class DynamicDbHelper( */ fun syncDatabase(response: DataResponse) { val db = writableDatabase + try { + db.beginTransaction() - // 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 - } + response.contract.fields.forEach { (tableName, columns) -> + val colDefinition = columns.keys.joinToString(", ") { "$it TEXT" } + db.execSQL("DROP TABLE IF EXISTS $tableName") + db.execSQL( + "CREATE TABLE $tableName " + + "(id INTEGER PRIMARY KEY AUTOINCREMENT, $colDefinition)", + ) + } - // Insert Data with Transaction. - db.beginTransaction() - try { response.data.forEach { (tableName, rows) -> - + val cv = ContentValues() rows.forEach { row -> - val cv = ContentValues() + cv.clear() 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.insert(tableName, null, cv) } } db.setTransactionSuccessful() @@ -65,6 +63,7 @@ class DynamicDbHelper( Log.e("SCRIBE_DB", "Error during insert: ${e.message}") } finally { db.endTransaction() + db.close() } } } diff --git a/app/src/main/java/be/scri/helpers/DatabaseFileManager.kt b/app/src/main/java/be/scri/helpers/DatabaseFileManager.kt index 52b12167..8b099063 100644 --- a/app/src/main/java/be/scri/helpers/DatabaseFileManager.kt +++ b/app/src/main/java/be/scri/helpers/DatabaseFileManager.kt @@ -37,7 +37,19 @@ class DatabaseFileManager( */ fun getLanguageDatabase(language: String): SQLiteDatabase? { val dbName = "${language}LanguageData.sqlite" - return getDatabase(dbName, "data/$dbName") + val dbFile = context.getDatabasePath(dbName) + + if (!dbFile.exists()) { + Log.w(TAG, "Database $dbName not found. User needs to download data first") + return null + } + + return try { + SQLiteDatabase.openDatabase(dbFile.path, null, SQLiteDatabase.OPEN_READONLY) + } catch (e: SQLiteException) { + Log.e(TAG, "Failed to open database $dbName", e) + null + } } /** diff --git a/app/src/main/java/be/scri/helpers/data/AutoSuggestionDataManager.kt b/app/src/main/java/be/scri/helpers/data/AutoSuggestionDataManager.kt index d55e11ad..1a208841 100644 --- a/app/src/main/java/be/scri/helpers/data/AutoSuggestionDataManager.kt +++ b/app/src/main/java/be/scri/helpers/data/AutoSuggestionDataManager.kt @@ -17,6 +17,7 @@ class AutoSuggestionDataManager( val suggestionMap = HashMap>() val columnsToSelect = listOf("word", "autosuggestion_0", "autosuggestion_1", "autosuggestion_2") + if (!db.tableExists("autosuggestions")) return suggestionMap db.rawQuery("SELECT * FROM autosuggestions LIMIT 1", null).use { tempCursor -> for (column in columnsToSelect) { if (tempCursor.getColumnIndex(column) == -1) { diff --git a/app/src/main/java/be/scri/helpers/data/AutocompletionDataManager.kt b/app/src/main/java/be/scri/helpers/data/AutocompletionDataManager.kt index 7be20938..0fd881e2 100644 --- a/app/src/main/java/be/scri/helpers/data/AutocompletionDataManager.kt +++ b/app/src/main/java/be/scri/helpers/data/AutocompletionDataManager.kt @@ -19,13 +19,18 @@ class AutocompletionDataManager( * @param language The language code (e.g. "en", "id") for which to load words. */ fun loadWords(language: String) { - val db = fileManager.getLanguageDatabase(language) - db?.rawQuery("SELECT word FROM autocomplete_lexicon", null).use { cursor -> - val wordIndex = cursor!!.getColumnIndex("word") - while (cursor.moveToNext()) { - val word = cursor.getString(wordIndex)?.lowercase()?.trim() - if (!word.isNullOrEmpty()) { - trie.insert(word) + val db = fileManager.getLanguageDatabase(language) ?: return + + db.use { database -> + if (!database.tableExists("autocomplete_lexicon")) return + + database.rawQuery("SELECT word FROM autocomplete_lexicon", null).use { cursor -> + val wordIndex = cursor.getColumnIndex("word") + while (cursor.moveToNext()) { + val word = cursor.getString(wordIndex)?.lowercase()?.trim() + if (!word.isNullOrEmpty()) { + trie.insert(word) + } } } } 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 e4b93db3..3b97d8eb 100644 --- a/app/src/main/java/be/scri/helpers/data/ConjugateDataManager.kt +++ b/app/src/main/java/be/scri/helpers/data/ConjugateDataManager.kt @@ -92,6 +92,15 @@ class ConjugateDataManager( ): String { if (form.isNullOrEmpty()) return "" return fileManager.getLanguageDatabase(language)?.use { db -> + if (!db.tableExists("verbs")) { + return "" + } + + val columnName = if (language == "SV") "verb" else "infinitive" + if (!db.columnExists("verbs", columnName)) { + return "" + } + getVerbCursor(db, word, language)?.use { cursor -> getConjugatedValueFromCursor(cursor, form, language) } diff --git a/app/src/main/java/be/scri/helpers/data/EmojiDataManager.kt b/app/src/main/java/be/scri/helpers/data/EmojiDataManager.kt index 0c02bcfc..f36c9701 100644 --- a/app/src/main/java/be/scri/helpers/data/EmojiDataManager.kt +++ b/app/src/main/java/be/scri/helpers/data/EmojiDataManager.kt @@ -29,6 +29,8 @@ class EmojiDataManager( val db = fileManager.getLanguageDatabase(language) ?: return emojiMap db.use { + if (!it.tableExists("emoji_keywords")) return emojiMap + it.rawQuery("SELECT MAX(LENGTH(word)) FROM emoji_keywords", null).use { cursor -> if (cursor.moveToFirst()) { maxKeywordLength = cursor.getInt(0) diff --git a/app/src/main/java/be/scri/helpers/data/PrepositionDataManager.kt b/app/src/main/java/be/scri/helpers/data/PrepositionDataManager.kt index 1750e154..884216de 100644 --- a/app/src/main/java/be/scri/helpers/data/PrepositionDataManager.kt +++ b/app/src/main/java/be/scri/helpers/data/PrepositionDataManager.kt @@ -28,6 +28,7 @@ class PrepositionDataManager( return hashMapOf() } return fileManager.getLanguageDatabase(language)?.use { db -> + if (!db.tableExists("prepositions")) return@use hashMapOf() db.rawQuery("SELECT preposition, grammaticalCase FROM prepositions", null).use { cursor -> processCursor(cursor) } // handle case where cursor is null diff --git a/app/src/main/java/be/scri/helpers/data/SQLiteExtensions.kt b/app/src/main/java/be/scri/helpers/data/SQLiteExtensions.kt new file mode 100644 index 00000000..5ed412a3 --- /dev/null +++ b/app/src/main/java/be/scri/helpers/data/SQLiteExtensions.kt @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package be.scri.helpers.data + +import android.database.sqlite.SQLiteDatabase + +fun SQLiteDatabase.tableExists(tableName: String): Boolean = + rawQuery( + "SELECT name FROM sqlite_master WHERE type='table' AND name='$tableName'", + null, + ).use { it.moveToFirst() } + +fun SQLiteDatabase.columnExists( + tableName: String, + columnName: String, +): Boolean = + rawQuery("PRAGMA table_info($tableName)", null).use { cursor -> + if (cursor.moveToFirst()) { + val nameIndex = cursor.getColumnIndex("name") + do { + if (cursor.getString(nameIndex) == columnName) { + return true + } + } while (cursor.moveToNext()) + } + false + } diff --git a/app/src/main/java/be/scri/ui/common/components/CircleClickableItemComp.kt b/app/src/main/java/be/scri/ui/common/components/CircleClickableItemComp.kt index c5933ba3..c79dba60 100644 --- a/app/src/main/java/be/scri/ui/common/components/CircleClickableItemComp.kt +++ b/app/src/main/java/be/scri/ui/common/components/CircleClickableItemComp.kt @@ -2,23 +2,36 @@ package be.scri.ui.common.components +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 import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.RadioButton import androidx.compose.material3.Text 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.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.res.colorResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import be.scri.R +import be.scri.ui.screens.download.CheckUpdateState /** * A composable component that displays a clickable item with a title and a circular clickable icon on the right. @@ -26,14 +39,20 @@ import androidx.compose.ui.unit.sp @Composable fun CircleClickableItemComp( title: String, - onClick: () -> Unit, + onStartCheck: () -> Unit, + onCancel: () -> Unit, modifier: Modifier = Modifier, - isSelected: Boolean = false, + checkState: CheckUpdateState = CheckUpdateState.Idle, + isDarkTheme: Boolean = false, ) { Box( modifier = - modifier - .clickable(onClick = onClick), + modifier.clickable { + when (checkState) { + CheckUpdateState.Idle, CheckUpdateState.Done -> onStartCheck() + CheckUpdateState.Checking -> onCancel() + } + }, ) { Column( modifier = @@ -56,12 +75,60 @@ fun CircleClickableItemComp( style = MaterialTheme.typography.bodyMedium, ) - RadioButton( - selected = isSelected, - onClick = { - onClick() - }, - ) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.size(36.dp), + ) { + when (checkState) { + CheckUpdateState.Idle -> { + Box( + modifier = + Modifier + .size(28.dp) + .border( + width = 2.dp, + color = if (isDarkTheme)colorResource(R.color.light_special_key_color) else colorResource(R.color.md_grey_600), + shape = CircleShape, + ), + ) + } + + CheckUpdateState.Checking -> { + CircularProgressIndicator( + modifier = Modifier.size(28.dp), + strokeWidth = 2.5.dp, + color = MaterialTheme.colorScheme.primary, + strokeCap = StrokeCap.Round, + ) + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Cancel", + tint = if (isDarkTheme) colorResource(R.color.light_special_key_color) else colorResource(R.color.md_grey_600), + modifier = Modifier.size(20.dp), + ) + } + + CheckUpdateState.Done -> { + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .size(28.dp) + .background( + color = MaterialTheme.colorScheme.primary, + shape = CircleShape, + ), + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = "Up to date", + tint = if (isDarkTheme) Color.Black else Color.White, + modifier = Modifier.size(20.dp), + ) + } + } + } + } } } } 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 386625f3..87b9ddaf 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 @@ -3,6 +3,7 @@ package be.scri.ui.screens.download import android.content.Context +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -30,6 +31,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -57,30 +59,26 @@ import kotlinx.collections.immutable.toImmutableList * * @param onBackNavigation Callback for back navigation action. * @param onNavigateToTranslation Callback for navigating to translation language selection. + * @param isDarkTheme Boolean indicating if the dark theme is enabled. + * @param checkUpdateActions Actions related to checking for data updates. + * @param downloadActions Actions related to downloading language data. * @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, + isDarkTheme: Boolean, + checkUpdateActions: CheckUpdateActions, + downloadActions: DownloadActions, modifier: Modifier = Modifier, - downloadStates: Map = emptyMap(), - onDownloadAction: (String, Boolean) -> Unit = { _, _ -> }, - onDownloadAll: () -> Unit = {}, - initializeStates: (List) -> Unit = {}, viewModel: SettingsViewModel = viewModel( factory = SettingsViewModelFactory(LocalContext.current), ), ) { - val currentInitializeStates by rememberUpdatedState(initializeStates) + val currentInitializeStates by rememberUpdatedState(downloadActions.initializeStates) val scrollState = rememberScrollState() - val checkForNewData = remember { mutableStateOf(false) } val regularlyUpdateData = remember { mutableStateOf(true) } val selectedLanguage = remember { mutableStateOf(null) } val context = LocalContext.current @@ -99,11 +97,10 @@ fun DownloadDataScreen( onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } - // Prepare the list of languages to display, including the "All Languages" option. + // Prepare the list of languages to display. val languages: ImmutableList = remember(installedKeyboardLanguages) { buildList { - 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()) { @@ -122,16 +119,7 @@ fun DownloadDataScreen( }.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) { + LaunchedEffect(Unit) { val keys = languages.map { it.key } currentInitializeStates(keys) } @@ -169,12 +157,11 @@ fun DownloadDataScreen( Column(Modifier.padding(vertical = 10.dp, horizontal = 4.dp)) { if (installedKeyboardLanguages.isNotEmpty()) { CircleClickableItemComp( + checkState = checkUpdateActions.checkUpdateState, + onStartCheck = checkUpdateActions.checkForNewData, + onCancel = checkUpdateActions.cancelCheckForNewData, 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, + isDarkTheme = isDarkTheme, ) HorizontalDivider( color = Color.Gray.copy(alpha = 0.3f), @@ -206,11 +193,10 @@ fun DownloadDataScreen( } else { LanguagesListSection( languages = languages, - allLanguagesState = allLanguagesState, - downloadStates = downloadStates, + downloadStates = downloadActions.downloadStates, onLanguageSelect = { selectedLanguage.value = it }, - onDownloadAll = onDownloadAll, - onDownloadAction = onDownloadAction, + onDownloadAll = downloadActions.onDownloadAll, + onDownloadAction = downloadActions.onDownloadAction, ) } @@ -234,7 +220,7 @@ fun DownloadDataScreen( ), textChange = stringResource(R.string.i18n_app_download_menu_ui_translation_source_tooltip_change_language), onConfirm = { - onDownloadAction(key, false) + downloadActions.onDownloadAction(key, false) selectedLanguage.value = null }, onChange = { onNavigateToTranslation(languageId) }, @@ -286,16 +272,14 @@ private fun EmptyStateSection(context: Context) { * 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 onDownloadAll Callback invoked when the "Update all" is clicked 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, @@ -310,16 +294,27 @@ private fun LanguagesListSection( color = MaterialTheme.colorScheme.surface, ) { Column(Modifier.padding(vertical = 10.dp, horizontal = 4.dp)) { + Text( + text = "Update all", + color = colorResource(R.color.dark_scribe_blue), + fontSize = 20.sp, + fontWeight = FontWeight.Medium, + modifier = + Modifier + .padding(horizontal = 12.dp, vertical = 10.dp) + .align(Alignment.End) + .clickable { + onDownloadAll() + }, + ) languages.forEachIndexed { index, lang -> - val currentStatus = if (lang.key == "all") allLanguagesState else (downloadStates[lang.key] ?: DownloadState.Ready) + val currentStatus = downloadStates[lang.key] ?: DownloadState.Ready LanguageItemComp( title = lang.displayName, onClick = { }, onButtonClick = { - if (lang.key == "all") { - onDownloadAll() - } else if (currentStatus == DownloadState.Ready) { + if (currentStatus == DownloadState.Ready) { onLanguageSelect(lang) } else { onDownloadAction(lang.key, false) 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 7d2255bb..6440259e 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 @@ -15,8 +15,13 @@ import be.scri.data.remote.RetrofitClient import be.scri.helpers.LanguageMappingConstants import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout import retrofit2.HttpException import java.io.IOException import java.time.LocalDate @@ -26,8 +31,13 @@ class DataDownloadViewModel( application: Application, ) : AndroidViewModel(application) { val downloadStates = mutableStateMapOf() + private val downloadSemaphore = kotlinx.coroutines.sync.Semaphore(1) private val downloadJobs = mutableMapOf() private val prefs = getApplication().getSharedPreferences("scribe_prefs", Context.MODE_PRIVATE) + private val _checkUpdateState = MutableStateFlow(CheckUpdateState.Idle) + val checkUpdateState = _checkUpdateState.asStateFlow() + + private var checkUpdateJob: Job? = null /** * Initializes the download states for the provided languages. @@ -36,7 +46,6 @@ class DataDownloadViewModel( */ fun initializeStates(languages: List) { languages.forEach { key -> - if (key == "all") return@forEach if (downloadStates.containsKey(key)) return@forEach val langCode = @@ -57,6 +66,7 @@ class DataDownloadViewModel( // After initializing, check for updates on all Completed languages. checkAllForUpdates() + _checkUpdateState.value = CheckUpdateState.Idle } /** @@ -115,9 +125,13 @@ class DataDownloadViewModel( // Store the job so we can cancel it later if needed. downloadJobs[key] = viewModelScope.launch(Dispatchers.IO) { + downloadSemaphore.acquire() try { // Fetch API. - val response = RetrofitClient.apiService.getData(langCode) + val response = + withTimeout(30_000) { + RetrofitClient.apiService.getData(langCode) + } val serverLastUpdate = response.contract.updatedAt // Always download when forcing, or when update is available. @@ -145,8 +159,12 @@ class DataDownloadViewModel( updateErrorState(key, "Database Error: ${e.message}") } catch (e: HttpException) { updateErrorState(key, "Server Error: ${e.code()}") + } catch (e: TimeoutCancellationException) { + updateErrorState(key, "Download timed out") + throw e } finally { // Clean up the job reference when done. + downloadSemaphore.release() downloadJobs.remove(key) } } @@ -158,7 +176,7 @@ class DataDownloadViewModel( fun handleDownloadAllLanguages() { val toDownload = downloadStates.keys.filter { key -> - key != "all" && downloadStates[key] != DownloadState.Completed && downloadStates[key] != DownloadState.Downloading + downloadStates[key] != DownloadState.Completed && downloadStates[key] != DownloadState.Downloading } toDownload.forEach { key -> handleDownloadAction(key) @@ -171,7 +189,7 @@ class DataDownloadViewModel( * * @param key The key identifying the download item. */ - fun checkForUpdates(key: String) { + private suspend fun checkForUpdates(key: String) { val currentState = downloadStates[key] ?: DownloadState.Ready if (currentState == DownloadState.Downloading) return @@ -182,46 +200,78 @@ class DataDownloadViewModel( val localLastUpdate = prefs.getString("last_update_$langCode", "1970-01-01") ?: "1970-01-01" - viewModelScope.launch(Dispatchers.IO) { - try { - val response = RetrofitClient.apiService.getDataVersion(langCode) + try { + val response = RetrofitClient.apiService.getDataVersion(langCode) - val hasUpdate = - response.versions.values.any { serverDate -> - isUpdateAvailable(localLastUpdate, serverDate) - } - - withContext(Dispatchers.Main) { - downloadStates[key] = - if (hasUpdate) { - DownloadState.Update - } else { - DownloadState.Completed - } + val hasUpdate = + response.versions.values.any { serverDate -> + isUpdateAvailable(localLastUpdate, serverDate) } - } 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}") + + 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() { + private 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) + viewModelScope.launch { checkForUpdates(key) } } } } + /** + * Checks for new data updates for all completed languages. + */ + fun checkForNewData() { + checkUpdateJob?.cancel() + + val keysToCheck = downloadStates.keys.filter { downloadStates[it] == DownloadState.Completed } + + if (keysToCheck.isEmpty()) { + _checkUpdateState.value = CheckUpdateState.Idle + return + } + + _checkUpdateState.value = CheckUpdateState.Checking + + checkUpdateJob = + viewModelScope.launch { + coroutineScope { + keysToCheck.forEach { key -> launch { checkForUpdates(key) } } + } + _checkUpdateState.value = CheckUpdateState.Done + } + } + + /** + * Cancels the ongoing check for available updates. + */ + fun cancelCheckForNewData() { + checkUpdateJob?.cancel() + checkUpdateJob = null + _checkUpdateState.value = CheckUpdateState.Idle + } + + /** + * Updates the error state for a given key and shows a toast message. + * + * @param key The key identifying the download item. + * @param message The error message to display in the toast. + */ private suspend fun updateErrorState( key: String, message: String, @@ -252,3 +302,40 @@ enum class DownloadState { Completed, Update, } + +/** + * Represents the state of the check for updates button. + */ +enum class CheckUpdateState { + Idle, + Checking, + Done, +} + +/** + * Data class to hold the state and actions related to downloading data. + * + * @param downloadStates A map of language keys to their current download states. + * @param onDownloadAction Callback for handling download actions when a language is selected and confirmed. + * @param onDownloadAll Callback for handling the action to download all languages. + * @param initializeStates Callback to initialize download states for given languages. + */ +data class DownloadActions( + val downloadStates: Map, + val onDownloadAction: (String, Boolean) -> Unit, + val onDownloadAll: () -> Unit, + val initializeStates: (List) -> Unit, +) + +/** + * Data class to hold the state and actions related to checking for updates. + * + * @param checkUpdateState The current state of checking for updates. + * @param checkForNewData Callback to initiate checking for new data updates. + * @param cancelCheckForNewData Callback to cancel the ongoing check for new data updates. + */ +data class CheckUpdateActions( + val checkUpdateState: CheckUpdateState, + val checkForNewData: () -> Unit, + val cancelCheckForNewData: () -> Unit, +)