diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoScreenWithLargeTopBar.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoScreenWithLargeTopBar.kt index 4cddcc93..f5fc3ef0 100644 --- a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoScreenWithLargeTopBar.kt +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoScreenWithLargeTopBar.kt @@ -19,6 +19,7 @@ package com.sadellie.unitto.core.ui.common import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -39,6 +40,7 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll fun UnittoScreenWithLargeTopBar( title: String, navigationIcon: @Composable () -> Unit, + actions: @Composable RowScope.() -> Unit = {}, content: @Composable (PaddingValues) -> Unit ) { val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( @@ -53,7 +55,8 @@ fun UnittoScreenWithLargeTopBar( Text(text = title) }, navigationIcon = navigationIcon, - scrollBehavior = scrollBehavior + scrollBehavior = scrollBehavior, + actions = actions ) }, content = content diff --git a/data/backup/.gitignore b/data/backup/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/data/backup/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/data/backup/build.gradle.kts b/data/backup/build.gradle.kts new file mode 100644 index 00000000..a3d51364 --- /dev/null +++ b/data/backup/build.gradle.kts @@ -0,0 +1,36 @@ +/* + * Unitto is a unit converter for Android + * Copyright (c) 2023 Elshan Agaev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +plugins { + id("unitto.library") + id("unitto.android.library.jacoco") + id("unitto.android.hilt") +} + +android { + namespace = "com.sadellie.unitto.data.backup" +} + +dependencies { + implementation(libs.androidx.datastore.datastore.preferences) + implementation(libs.com.squareup.moshi.moshi.kotlin) + + implementation(project(":data:database")) + implementation(project(":data:model")) + implementation(project(":data:userprefs")) +} diff --git a/data/backup/consumer-rules.pro b/data/backup/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/data/backup/src/androidTest/java/com/sadellie/unitto/data/backup/BackupManagerTest.kt b/data/backup/src/androidTest/java/com/sadellie/unitto/data/backup/BackupManagerTest.kt new file mode 100644 index 00000000..4173a952 --- /dev/null +++ b/data/backup/src/androidTest/java/com/sadellie/unitto/data/backup/BackupManagerTest.kt @@ -0,0 +1,206 @@ +/* + * Unitto is a unit converter for Android + * Copyright (c) 2023 Elshan Agaev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.sadellie.unitto.data.backup + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStoreFile +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.sadellie.unitto.data.userprefs.PrefsKeys +import com.sadellie.unitto.data.userprefs.getThemingMode +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class BackupManagerTest { + + private lateinit var dataStore: DataStore + private lateinit var backupManager: BackupManager + + @Before + fun setup() { + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + + dataStore = PreferenceDataStoreFactory.create( + corruptionHandler = null, + produceFile = { appContext.preferencesDataStoreFile("test") } + ) + + backupManager = BackupManager( + mContext = appContext, + dataStore = dataStore, + unitsDao = FakeUnitsDao, + timeZoneDao = FakeTimeZoneDao + ) + + runBlocking { + dataStore.edit { + it[PrefsKeys.THEMING_MODE] = FakeUsrPreferenceValues.themingMode + it[PrefsKeys.ENABLE_DYNAMIC_THEME] = FakeUsrPreferenceValues.enableDynamicTheme + it[PrefsKeys.ENABLE_AMOLED_THEME] = FakeUsrPreferenceValues.enableAmoledTheme + it[PrefsKeys.CUSTOM_COLOR] = FakeUsrPreferenceValues.customColor + it[PrefsKeys.MONET_MODE] = FakeUsrPreferenceValues.monetMode + it[PrefsKeys.STARTING_SCREEN] = FakeUsrPreferenceValues.startingScreen + it[PrefsKeys.ENABLE_TOOLS_EXPERIMENT] = FakeUsrPreferenceValues.enableToolsExperiment + it[PrefsKeys.ENABLE_VIBRATIONS] = FakeUsrPreferenceValues.enableVibrations + it[PrefsKeys.MIDDLE_ZERO] = FakeUsrPreferenceValues.middleZero + it[PrefsKeys.AC_BUTTON] = FakeUsrPreferenceValues.acButton + it[PrefsKeys.RPN_MODE] = FakeUsrPreferenceValues.rpnMode + + // FORMATTER + it[PrefsKeys.DIGITS_PRECISION] = FakeUsrPreferenceValues.precision + it[PrefsKeys.SEPARATOR] = FakeUsrPreferenceValues.separator + it[PrefsKeys.OUTPUT_FORMAT] = FakeUsrPreferenceValues.outputFormat + + // CALCULATOR + it[PrefsKeys.RADIAN_MODE] = FakeUsrPreferenceValues.radianMode + it[PrefsKeys.PARTIAL_HISTORY_VIEW] = FakeUsrPreferenceValues.partialHistoryView + it[PrefsKeys.CLEAR_INPUT_AFTER_EQUALS] = FakeUsrPreferenceValues.clearInputAfterEquals + + // UNIT CONVERTER + it[PrefsKeys.LATEST_LEFT_SIDE] = FakeUsrPreferenceValues.latestLeftSide + it[PrefsKeys.LATEST_RIGHT_SIDE] = FakeUsrPreferenceValues.latestRightSide + it[PrefsKeys.SHOWN_UNIT_GROUPS] = FakeUsrPreferenceValues.shownUnitGroups.joinToString(",") + it[PrefsKeys.UNIT_CONVERTER_FAVORITES_ONLY] = FakeUsrPreferenceValues.unitConverterFavoritesOnly + it[PrefsKeys.UNIT_CONVERTER_FORMAT_TIME] = FakeUsrPreferenceValues.unitConverterFormatTime + it[PrefsKeys.UNIT_CONVERTER_SORTING] = FakeUsrPreferenceValues.unitConverterSorting.name + } + } + } + + @Test + fun getUserDataTest() = runBlocking{ + val actualUserData = backupManager.userDataFromApp() + val expectedUserData = UserData( + themingMode = FakeUsrPreferenceValues.themingMode, + enableDynamicTheme = FakeUsrPreferenceValues.enableDynamicTheme, + enableAmoledTheme = FakeUsrPreferenceValues.enableAmoledTheme, + customColor = FakeUsrPreferenceValues.customColor, + monetMode = FakeUsrPreferenceValues.monetMode, + startingScreen = FakeUsrPreferenceValues.startingScreen, + enableToolsExperiment = FakeUsrPreferenceValues.enableToolsExperiment, + enableVibrations = FakeUsrPreferenceValues.enableVibrations, + middleZero = FakeUsrPreferenceValues.middleZero, + acButton = FakeUsrPreferenceValues.acButton, + rpnMode = FakeUsrPreferenceValues.rpnMode, + precision = FakeUsrPreferenceValues.precision, + separator = FakeUsrPreferenceValues.separator, + outputFormat = FakeUsrPreferenceValues.outputFormat, + radianMode = FakeUsrPreferenceValues.radianMode, + partialHistoryView = FakeUsrPreferenceValues.partialHistoryView, + clearInputAfterEquals = FakeUsrPreferenceValues.clearInputAfterEquals, + latestLeftSide = FakeUsrPreferenceValues.latestLeftSide, + latestRightSide = FakeUsrPreferenceValues.latestRightSide, + shownUnitGroups = FakeUsrPreferenceValues.shownUnitGroups, + unitConverterFavoritesOnly = FakeUsrPreferenceValues.unitConverterFavoritesOnly, + unitConverterFormatTime = FakeUsrPreferenceValues.unitConverterFormatTime, + unitConverterSorting = FakeUsrPreferenceValues.unitConverterSorting, + unitsTable = units, + timeZoneTable = timeZones + ) + + assertEquals(expectedUserData, actualUserData) + } + + @Test + fun updateDatastoreTest() = runBlocking{ + backupManager.updateDatastore( + UserData( + themingMode = FakeUsrPreferenceValues.themingMode, + enableDynamicTheme = FakeUsrPreferenceValues.enableDynamicTheme, + enableAmoledTheme = FakeUsrPreferenceValues.enableAmoledTheme, + customColor = FakeUsrPreferenceValues.customColor, + monetMode = FakeUsrPreferenceValues.monetMode, + startingScreen = FakeUsrPreferenceValues.startingScreen, + enableToolsExperiment = FakeUsrPreferenceValues.enableToolsExperiment, + enableVibrations = FakeUsrPreferenceValues.enableVibrations, + middleZero = FakeUsrPreferenceValues.middleZero, + acButton = FakeUsrPreferenceValues.acButton, + rpnMode = FakeUsrPreferenceValues.rpnMode, + precision = FakeUsrPreferenceValues.precision, + separator = FakeUsrPreferenceValues.separator, + outputFormat = FakeUsrPreferenceValues.outputFormat, + radianMode = FakeUsrPreferenceValues.radianMode, + partialHistoryView = FakeUsrPreferenceValues.partialHistoryView, + clearInputAfterEquals = FakeUsrPreferenceValues.clearInputAfterEquals, + latestLeftSide = FakeUsrPreferenceValues.latestLeftSide, + latestRightSide = FakeUsrPreferenceValues.latestRightSide, + shownUnitGroups = FakeUsrPreferenceValues.shownUnitGroups, + unitConverterFavoritesOnly = FakeUsrPreferenceValues.unitConverterFavoritesOnly, + unitConverterFormatTime = FakeUsrPreferenceValues.unitConverterFormatTime, + unitConverterSorting = FakeUsrPreferenceValues.unitConverterSorting, + unitsTable = units, + timeZoneTable = timeZones + ) + ) + + val data = dataStore.data.first() + // TODO Wrong implementation, should test all + assertEquals(FakeUsrPreferenceValues.themingMode, data.getThemingMode()) + } + + @Test + fun updateUnitsTableTest() = runBlocking { + backupManager.updateUnitsTable( + UserData( + themingMode = FakeUsrPreferenceValues.themingMode, + enableDynamicTheme = FakeUsrPreferenceValues.enableDynamicTheme, + enableAmoledTheme = FakeUsrPreferenceValues.enableAmoledTheme, + customColor = FakeUsrPreferenceValues.customColor, + monetMode = FakeUsrPreferenceValues.monetMode, + startingScreen = FakeUsrPreferenceValues.startingScreen, + enableToolsExperiment = FakeUsrPreferenceValues.enableToolsExperiment, + enableVibrations = FakeUsrPreferenceValues.enableVibrations, + middleZero = FakeUsrPreferenceValues.middleZero, + acButton = FakeUsrPreferenceValues.acButton, + rpnMode = FakeUsrPreferenceValues.rpnMode, + precision = FakeUsrPreferenceValues.precision, + separator = FakeUsrPreferenceValues.separator, + outputFormat = FakeUsrPreferenceValues.outputFormat, + radianMode = FakeUsrPreferenceValues.radianMode, + partialHistoryView = FakeUsrPreferenceValues.partialHistoryView, + clearInputAfterEquals = FakeUsrPreferenceValues.clearInputAfterEquals, + latestLeftSide = FakeUsrPreferenceValues.latestLeftSide, + latestRightSide = FakeUsrPreferenceValues.latestRightSide, + shownUnitGroups = FakeUsrPreferenceValues.shownUnitGroups, + unitConverterFavoritesOnly = FakeUsrPreferenceValues.unitConverterFavoritesOnly, + unitConverterFormatTime = FakeUsrPreferenceValues.unitConverterFormatTime, + unitConverterSorting = FakeUsrPreferenceValues.unitConverterSorting, + unitsTable = emptyList(), + timeZoneTable = timeZones + ) + ) + + val data = FakeUnitsDao.getAllFlow().first() + // TODO Wrong implementation + assertEquals("$units | $data", units, data) + } +} diff --git a/data/backup/src/androidTest/java/com/sadellie/unitto/data/backup/FakeTimeZoneDao.kt b/data/backup/src/androidTest/java/com/sadellie/unitto/data/backup/FakeTimeZoneDao.kt new file mode 100644 index 00000000..25cb4ef3 --- /dev/null +++ b/data/backup/src/androidTest/java/com/sadellie/unitto/data/backup/FakeTimeZoneDao.kt @@ -0,0 +1,54 @@ +/* + * Unitto is a unit converter for Android + * Copyright (c) 2023 Elshan Agaev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.sadellie.unitto.data.backup + +import com.sadellie.unitto.data.database.TimeZoneDao +import com.sadellie.unitto.data.database.TimeZoneEntity +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +val timeZones = listOf( + TimeZoneEntity( + id = "id1", + position = 9, + label = "label" + ), + TimeZoneEntity( + id = "id2", + position = 9, + label = "label" + ) +) + +object FakeTimeZoneDao: TimeZoneDao { + override fun getFavorites(): Flow> { + return flow { + emit(timeZones) + } + } + + override fun getMaxPosition(): Int = 0 + override suspend fun insert(vararg timeZoneEntity: TimeZoneEntity) {} + override suspend fun removeFromFavorites(id: String) {} + override suspend fun updateLabel(id: String, label: String) {} + override suspend fun updateDragged(id: String, oldPosition: Int, newPosition: Int) {} + override suspend fun moveDown(currentPosition: Int, targetPosition: Int) {} + override suspend fun moveUp(currentPosition: Int, targetPosition: Int) {} + override suspend fun clear() {} +} \ No newline at end of file diff --git a/data/backup/src/androidTest/java/com/sadellie/unitto/data/backup/FakeUnitsDao.kt b/data/backup/src/androidTest/java/com/sadellie/unitto/data/backup/FakeUnitsDao.kt new file mode 100644 index 00000000..a1a6dfc0 --- /dev/null +++ b/data/backup/src/androidTest/java/com/sadellie/unitto/data/backup/FakeUnitsDao.kt @@ -0,0 +1,51 @@ +/* + * Unitto is a unit converter for Android + * Copyright (c) 2023 Elshan Agaev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.sadellie.unitto.data.backup + +import com.sadellie.unitto.data.database.UnitsDao +import com.sadellie.unitto.data.database.UnitsEntity +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +val units = listOf( + UnitsEntity( + unitId = "UnitId1", + isFavorite = false, + pairedUnitId = "Pair", + frequency = 9 + ), + UnitsEntity( + unitId = "UnitId2", + isFavorite = false, + pairedUnitId = "Pair", + frequency = 9 + ) +) + +object FakeUnitsDao : UnitsDao { + override fun getAllFlow(): Flow> { + return flow { + emit(units) + } + } + + override suspend fun insertUnit(unit: UnitsEntity) {} + override suspend fun getById(unitId: String): UnitsEntity? = null + override suspend fun clear() {} +} \ No newline at end of file diff --git a/data/backup/src/androidTest/java/com/sadellie/unitto/data/backup/FakeUserPreferencesRepository.kt b/data/backup/src/androidTest/java/com/sadellie/unitto/data/backup/FakeUserPreferencesRepository.kt new file mode 100644 index 00000000..0abb45bd --- /dev/null +++ b/data/backup/src/androidTest/java/com/sadellie/unitto/data/backup/FakeUserPreferencesRepository.kt @@ -0,0 +1,48 @@ +/* + * Unitto is a unit converter for Android + * Copyright (c) 2023 Elshan Agaev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.sadellie.unitto.data.backup + +import com.sadellie.unitto.data.model.ALL_UNIT_GROUPS +import com.sadellie.unitto.data.model.UnitsListSorting + +object FakeUsrPreferenceValues { + const val themingMode = "ThemingMode" + const val enableDynamicTheme = false + const val enableAmoledTheme = false + const val customColor = 777L + const val monetMode = "MonetMode" + const val startingScreen = "StartingScreen" + const val enableToolsExperiment = false + const val enableVibrations = false + const val middleZero = false + const val acButton = false + const val rpnMode = false + const val precision = 69 + const val separator = 1 + const val outputFormat = 1 + const val radianMode = false + const val partialHistoryView = false + const val clearInputAfterEquals = false + const val latestLeftSide = "LeftSideUnit" + const val latestRightSide = "RightSideUnit" + val shownUnitGroups = ALL_UNIT_GROUPS + const val unitConverterFavoritesOnly = false + const val unitConverterFormatTime = false + val unitConverterSorting = UnitsListSorting.USAGE +} diff --git a/data/backup/src/main/AndroidManifest.xml b/data/backup/src/main/AndroidManifest.xml new file mode 100644 index 00000000..6a27ed23 --- /dev/null +++ b/data/backup/src/main/AndroidManifest.xml @@ -0,0 +1,32 @@ + + + + + + + + + + diff --git a/data/backup/src/main/java/com/sadellie/unitto/data/backup/BackupManager.kt b/data/backup/src/main/java/com/sadellie/unitto/data/backup/BackupManager.kt new file mode 100644 index 00000000..7dc76001 --- /dev/null +++ b/data/backup/src/main/java/com/sadellie/unitto/data/backup/BackupManager.kt @@ -0,0 +1,191 @@ +/* + * Unitto is a unit converter for Android + * Copyright (c) 2023 Elshan Agaev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.sadellie.unitto.data.backup + +import android.content.Context +import android.net.Uri +import androidx.core.content.FileProvider +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import com.sadellie.unitto.data.database.TimeZoneDao +import com.sadellie.unitto.data.database.UnitsDao +import com.sadellie.unitto.data.userprefs.PrefsKeys +import com.sadellie.unitto.data.userprefs.getAcButton +import com.sadellie.unitto.data.userprefs.getClearInputAfterEquals +import com.sadellie.unitto.data.userprefs.getCustomColor +import com.sadellie.unitto.data.userprefs.getDigitsPrecision +import com.sadellie.unitto.data.userprefs.getEnableAmoledTheme +import com.sadellie.unitto.data.userprefs.getEnableDynamicTheme +import com.sadellie.unitto.data.userprefs.getEnableToolsExperiment +import com.sadellie.unitto.data.userprefs.getEnableVibrations +import com.sadellie.unitto.data.userprefs.getLatestLeftSide +import com.sadellie.unitto.data.userprefs.getLatestRightSide +import com.sadellie.unitto.data.userprefs.getMiddleZero +import com.sadellie.unitto.data.userprefs.getMonetMode +import com.sadellie.unitto.data.userprefs.getOutputFormat +import com.sadellie.unitto.data.userprefs.getPartialHistoryView +import com.sadellie.unitto.data.userprefs.getRadianMode +import com.sadellie.unitto.data.userprefs.getRpnMode +import com.sadellie.unitto.data.userprefs.getSeparator +import com.sadellie.unitto.data.userprefs.getShownUnitGroups +import com.sadellie.unitto.data.userprefs.getStartingScreen +import com.sadellie.unitto.data.userprefs.getThemingMode +import com.sadellie.unitto.data.userprefs.getUnitConverterFavoritesOnly +import com.sadellie.unitto.data.userprefs.getUnitConverterFormatTime +import com.sadellie.unitto.data.userprefs.getUnitConverterSorting +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import java.io.BufferedReader +import java.io.File +import java.io.InputStreamReader +import javax.inject.Inject + +class BackupManager @Inject constructor( + @ApplicationContext private val mContext: Context, + private val dataStore: DataStore, + private val unitsDao: UnitsDao, + private val timeZoneDao: TimeZoneDao, + // Not planned at the moment + // private val calculatorHistoryDao: CalculatorHistoryDao, +) { + private val moshi: Moshi = Moshi.Builder() + .addLast(KotlinJsonAdapterFactory()) + .build() + private val jsonAdapter: JsonAdapter = moshi.adapter(UserData::class.java) + private val auth = "com.sadellie.unitto.BackupManager" + + suspend fun backup(): Uri = withContext(Dispatchers.IO) { + val userData = userDataFromApp() + // save to disk + val tempFile = File.createTempFile("backup", ".unitto", mContext.cacheDir) + tempFile.writeText(jsonAdapter.toJson(userData)) + + return@withContext FileProvider.getUriForFile(mContext, auth, tempFile) + } + + suspend fun restore(uri: Uri) = withContext(Dispatchers.IO) { + val jsonContent = StringBuilder() + mContext.contentResolver.openInputStream(uri)?.use { inputStream -> + BufferedReader(InputStreamReader(inputStream)).use { reader -> + var line: String? = reader.readLine() + while (line != null) { + jsonContent.append(line) + line = reader.readLine() + } + } + } + + // Return error + val userData: UserData = jsonAdapter.fromJson(jsonContent.toString()) ?: return@withContext IllegalArgumentException("Can't parse: $jsonContent") + + // Apply tables + updateUnitsTable(userData) + updateTimeZonesTable(userData) + + // Apply datastore prefs + // Datastore settings are restored at the end, because it will trigger recomposition of the + // entire app composable and all jobs in ViewModels will be canceled + updateDatastore(userData) + } + + internal suspend fun userDataFromApp(): UserData { + val data = dataStore.data.first() + val unitsTableData = unitsDao.getAllFlow().first() + val timeZoneTableData = timeZoneDao.getFavorites().first() + + return UserData( + themingMode = data.getThemingMode(), + enableDynamicTheme = data.getEnableDynamicTheme(), + enableAmoledTheme = data.getEnableAmoledTheme(), + customColor = data.getCustomColor(), + monetMode = data.getMonetMode(), + startingScreen = data.getStartingScreen(), + enableToolsExperiment = data.getEnableToolsExperiment(), + enableVibrations = data.getEnableVibrations(), + middleZero = data.getMiddleZero(), + acButton = data.getAcButton(), + rpnMode = data.getRpnMode(), + precision = data.getDigitsPrecision(), + separator = data.getSeparator(), + outputFormat = data.getOutputFormat(), + radianMode = data.getRadianMode(), + partialHistoryView = data.getPartialHistoryView(), + clearInputAfterEquals = data.getClearInputAfterEquals(), + latestLeftSide = data.getLatestLeftSide(), + latestRightSide = data.getLatestRightSide(), + shownUnitGroups = data.getShownUnitGroups(), + unitConverterFavoritesOnly = data.getUnitConverterFavoritesOnly(), + unitConverterFormatTime = data.getUnitConverterFormatTime(), + unitConverterSorting = data.getUnitConverterSorting(), + unitsTable = unitsTableData, + timeZoneTable = timeZoneTableData, + ) + } + + internal suspend fun updateDatastore(userData: UserData) { + dataStore.edit { it.clear() } + dataStore.edit { + it[PrefsKeys.THEMING_MODE] = userData.themingMode + it[PrefsKeys.ENABLE_DYNAMIC_THEME] = userData.enableDynamicTheme + it[PrefsKeys.ENABLE_AMOLED_THEME] = userData.enableAmoledTheme + it[PrefsKeys.CUSTOM_COLOR] = userData.customColor + it[PrefsKeys.MONET_MODE] = userData.monetMode + it[PrefsKeys.STARTING_SCREEN] = userData.startingScreen + it[PrefsKeys.ENABLE_TOOLS_EXPERIMENT] = userData.enableToolsExperiment + it[PrefsKeys.ENABLE_VIBRATIONS] = userData.enableVibrations + it[PrefsKeys.MIDDLE_ZERO] = userData.middleZero + it[PrefsKeys.AC_BUTTON] = userData.acButton + it[PrefsKeys.RPN_MODE] = userData.rpnMode + + // FORMATTER + it[PrefsKeys.DIGITS_PRECISION] = userData.precision + it[PrefsKeys.SEPARATOR] = userData.separator + it[PrefsKeys.OUTPUT_FORMAT] = userData.outputFormat + + // CALCULATOR + it[PrefsKeys.RADIAN_MODE] = userData.radianMode + it[PrefsKeys.PARTIAL_HISTORY_VIEW] = userData.partialHistoryView + it[PrefsKeys.CLEAR_INPUT_AFTER_EQUALS] = userData.clearInputAfterEquals + + // UNIT CONVERTER + it[PrefsKeys.LATEST_LEFT_SIDE] = userData.latestLeftSide + it[PrefsKeys.LATEST_RIGHT_SIDE] = userData.latestRightSide + it[PrefsKeys.SHOWN_UNIT_GROUPS] = userData.shownUnitGroups.joinToString(",") + it[PrefsKeys.UNIT_CONVERTER_FAVORITES_ONLY] = userData.unitConverterFavoritesOnly + it[PrefsKeys.UNIT_CONVERTER_FORMAT_TIME] = userData.unitConverterFormatTime + it[PrefsKeys.UNIT_CONVERTER_SORTING] = userData.unitConverterSorting.name + } + } + + internal suspend fun updateUnitsTable(userData: UserData) { + unitsDao.clear() + userData.unitsTable.forEach { unitsDao.insertUnit(it) } + } + + internal suspend fun updateTimeZonesTable(userData: UserData) { + timeZoneDao.clear() + userData.timeZoneTable.forEach { timeZoneDao.insert(it) } + } +} diff --git a/data/backup/src/main/java/com/sadellie/unitto/data/backup/UserData.kt b/data/backup/src/main/java/com/sadellie/unitto/data/backup/UserData.kt new file mode 100644 index 00000000..e76582f1 --- /dev/null +++ b/data/backup/src/main/java/com/sadellie/unitto/data/backup/UserData.kt @@ -0,0 +1,57 @@ +/* + * Unitto is a unit converter for Android + * Copyright (c) 2023 Elshan Agaev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.sadellie.unitto.data.backup + +import com.sadellie.unitto.data.database.TimeZoneEntity +import com.sadellie.unitto.data.database.UnitsEntity +import com.sadellie.unitto.data.model.UnitGroup +import com.sadellie.unitto.data.model.UnitsListSorting + +// Don't move to model module. This uses entity classes from database module +data class UserData( + val themingMode: String, + val enableDynamicTheme: Boolean, + val enableAmoledTheme: Boolean, + val customColor: Long, + val monetMode: String, + val startingScreen: String, + val enableToolsExperiment: Boolean, + val enableVibrations: Boolean, + val middleZero: Boolean, + val acButton: Boolean, + val rpnMode: Boolean, + + val precision: Int, + val separator: Int, + val outputFormat: Int, + + val radianMode: Boolean, + val partialHistoryView: Boolean, + val clearInputAfterEquals: Boolean, + + val latestLeftSide: String, + val latestRightSide: String, + val shownUnitGroups: List, + val unitConverterFavoritesOnly: Boolean, + val unitConverterFormatTime: Boolean, + val unitConverterSorting: UnitsListSorting, + + val unitsTable: List, + val timeZoneTable: List, +) diff --git a/data/backup/src/main/res/xml/file_provider_path.xml b/data/backup/src/main/res/xml/file_provider_path.xml new file mode 100644 index 00000000..ac221c89 --- /dev/null +++ b/data/backup/src/main/res/xml/file_provider_path.xml @@ -0,0 +1,21 @@ + + + + + diff --git a/data/database/src/main/java/com/sadellie/unitto/data/database/TimeZoneDao.kt b/data/database/src/main/java/com/sadellie/unitto/data/database/TimeZoneDao.kt index 1984def3..ff971b51 100644 --- a/data/database/src/main/java/com/sadellie/unitto/data/database/TimeZoneDao.kt +++ b/data/database/src/main/java/com/sadellie/unitto/data/database/TimeZoneDao.kt @@ -74,4 +74,7 @@ interface TimeZoneDao { } updateDragged(id, 0, targetPosition) } + + @Query("DELETE FROM time_zones") + suspend fun clear() } diff --git a/data/database/src/main/java/com/sadellie/unitto/data/database/UnitsDao.kt b/data/database/src/main/java/com/sadellie/unitto/data/database/UnitsDao.kt index 5e03da1c..659a5110 100644 --- a/data/database/src/main/java/com/sadellie/unitto/data/database/UnitsDao.kt +++ b/data/database/src/main/java/com/sadellie/unitto/data/database/UnitsDao.kt @@ -35,4 +35,7 @@ interface UnitsDao { @Query("SELECT * FROM units WHERE unitId == :unitId LIMIT 1") suspend fun getById(unitId: String): UnitsEntity? + + @Query("DELETE FROM units") + suspend fun clear() } diff --git a/data/userprefs/src/main/java/com/sadellie/unitto/data/userprefs/PreferenceExt.kt b/data/userprefs/src/main/java/com/sadellie/unitto/data/userprefs/PreferenceExt.kt new file mode 100644 index 00000000..33c06724 --- /dev/null +++ b/data/userprefs/src/main/java/com/sadellie/unitto/data/userprefs/PreferenceExt.kt @@ -0,0 +1,135 @@ +/* + * Unitto is a unit converter for Android + * Copyright (c) 2023 Elshan Agaev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.sadellie.unitto.data.userprefs + +import androidx.datastore.preferences.core.Preferences +import com.sadellie.unitto.core.base.OutputFormat +import com.sadellie.unitto.core.base.Separator +import com.sadellie.unitto.core.base.TopLevelDestinations +import com.sadellie.unitto.data.model.ALL_UNIT_GROUPS +import com.sadellie.unitto.data.model.UnitGroup +import com.sadellie.unitto.data.model.UnitsListSorting +import com.sadellie.unitto.data.units.MyUnitIDS + +fun Preferences.getEnableDynamicTheme(): Boolean { + return this[PrefsKeys.ENABLE_DYNAMIC_THEME] ?: true +} + +fun Preferences.getThemingMode(): String { + return this[PrefsKeys.THEMING_MODE] ?: "" +} + +fun Preferences.getEnableAmoledTheme(): Boolean { + return this[PrefsKeys.ENABLE_AMOLED_THEME] ?: false +} + +fun Preferences.getCustomColor(): Long { + return this[PrefsKeys.CUSTOM_COLOR] ?: 16L // From Color.Unspecified +} + +fun Preferences.getMonetMode(): String { + return this[PrefsKeys.MONET_MODE] ?: "" +} + +fun Preferences.getStartingScreen(): String { + return this[PrefsKeys.STARTING_SCREEN] ?: TopLevelDestinations.Calculator.graph +} + +fun Preferences.getEnableToolsExperiment(): Boolean { + return this[PrefsKeys.ENABLE_TOOLS_EXPERIMENT] ?: false +} + +fun Preferences.getEnableVibrations(): Boolean { + return this[PrefsKeys.ENABLE_VIBRATIONS] ?: true +} + +fun Preferences.getRadianMode(): Boolean { + return this[PrefsKeys.RADIAN_MODE] ?: true +} + +fun Preferences.getSeparator(): Int { + return this[PrefsKeys.SEPARATOR] ?: Separator.SPACE +} + +fun Preferences.getMiddleZero(): Boolean { + return this[PrefsKeys.MIDDLE_ZERO] ?: true +} + +fun Preferences.getPartialHistoryView(): Boolean { + return this[PrefsKeys.PARTIAL_HISTORY_VIEW] ?: true +} + +fun Preferences.getDigitsPrecision(): Int { + return this[PrefsKeys.DIGITS_PRECISION] ?: 3 +} + +fun Preferences.getOutputFormat(): Int { + return this[PrefsKeys.OUTPUT_FORMAT] ?: OutputFormat.PLAIN +} + +fun Preferences.getUnitConverterFormatTime(): Boolean { + return this[PrefsKeys.UNIT_CONVERTER_FORMAT_TIME] ?: false +} + +fun Preferences.getUnitConverterSorting(): UnitsListSorting { + return this[PrefsKeys.UNIT_CONVERTER_SORTING] + ?.let { UnitsListSorting.valueOf(it) } ?: UnitsListSorting.USAGE +} + +fun Preferences.getShownUnitGroups(): List { + return this[PrefsKeys.SHOWN_UNIT_GROUPS] + ?.letTryOrNull { list -> + list + .ifEmpty { return@letTryOrNull listOf() } + .split(",") + .map { UnitGroup.valueOf(it) } + } + ?: ALL_UNIT_GROUPS +} + +fun Preferences.getUnitConverterFavoritesOnly(): Boolean { + return this[PrefsKeys.UNIT_CONVERTER_FAVORITES_ONLY] + ?: false +} + +fun Preferences.getLatestLeftSide(): String { + return this[PrefsKeys.LATEST_LEFT_SIDE] ?: MyUnitIDS.kilometer +} + +fun Preferences.getLatestRightSide(): String { + return this[PrefsKeys.LATEST_RIGHT_SIDE] ?: MyUnitIDS.mile +} + +fun Preferences.getAcButton(): Boolean { + return this[PrefsKeys.AC_BUTTON] ?: true +} + +fun Preferences.getClearInputAfterEquals(): Boolean { + return this[PrefsKeys.CLEAR_INPUT_AFTER_EQUALS] ?: true +} + +fun Preferences.getRpnMode(): Boolean { + return this[PrefsKeys.RPN_MODE] ?: false +} + +private inline fun T.letTryOrNull(block: (T) -> R): R? = try { + this?.let(block) +} catch (e: Exception) { + null +} \ No newline at end of file diff --git a/data/userprefs/src/main/java/com/sadellie/unitto/data/userprefs/PrefsKeys.kt b/data/userprefs/src/main/java/com/sadellie/unitto/data/userprefs/PrefsKeys.kt index 5ea204db..2b01559a 100644 --- a/data/userprefs/src/main/java/com/sadellie/unitto/data/userprefs/PrefsKeys.kt +++ b/data/userprefs/src/main/java/com/sadellie/unitto/data/userprefs/PrefsKeys.kt @@ -23,7 +23,7 @@ import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey -internal object PrefsKeys { +object PrefsKeys { // COMMON val THEMING_MODE = stringPreferencesKey("THEMING_MODE_PREF_KEY") val ENABLE_DYNAMIC_THEME = booleanPreferencesKey("ENABLE_DYNAMIC_THEME_PREF_KEY") diff --git a/data/userprefs/src/main/java/com/sadellie/unitto/data/userprefs/UserPreferences.kt b/data/userprefs/src/main/java/com/sadellie/unitto/data/userprefs/UserPreferences.kt index 0d722d04..7296a273 100644 --- a/data/userprefs/src/main/java/com/sadellie/unitto/data/userprefs/UserPreferences.kt +++ b/data/userprefs/src/main/java/com/sadellie/unitto/data/userprefs/UserPreferences.kt @@ -22,10 +22,6 @@ import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.emptyPreferences -import com.sadellie.unitto.core.base.OutputFormat -import com.sadellie.unitto.core.base.Separator -import com.sadellie.unitto.core.base.TopLevelDestinations -import com.sadellie.unitto.data.model.ALL_UNIT_GROUPS import com.sadellie.unitto.data.model.UnitGroup import com.sadellie.unitto.data.model.UnitsListSorting import com.sadellie.unitto.data.model.repository.UserPreferencesRepository @@ -40,7 +36,6 @@ import com.sadellie.unitto.data.model.userprefs.FormattingPreferences import com.sadellie.unitto.data.model.userprefs.GeneralPreferences import com.sadellie.unitto.data.model.userprefs.StartingScreenPreferences import com.sadellie.unitto.data.model.userprefs.UnitGroupsPreferences -import com.sadellie.unitto.data.units.MyUnitIDS import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map @@ -287,107 +282,3 @@ class UserPreferencesRepositoryImpl @Inject constructor( } } } - -private fun Preferences.getEnableDynamicTheme(): Boolean { - return this[PrefsKeys.ENABLE_DYNAMIC_THEME] ?: true -} - -private fun Preferences.getThemingMode(): String { - return this[PrefsKeys.THEMING_MODE] ?: "" -} - -private fun Preferences.getEnableAmoledTheme(): Boolean { - return this[PrefsKeys.ENABLE_AMOLED_THEME] ?: false -} - -private fun Preferences.getCustomColor(): Long { - return this[PrefsKeys.CUSTOM_COLOR] ?: 16L // From Color.Unspecified -} - -private fun Preferences.getMonetMode(): String { - return this[PrefsKeys.MONET_MODE] ?: "" -} - -private fun Preferences.getStartingScreen(): String { - return this[PrefsKeys.STARTING_SCREEN] - ?: TopLevelDestinations.Calculator.graph -} - -private fun Preferences.getEnableToolsExperiment(): Boolean { - return this[PrefsKeys.ENABLE_TOOLS_EXPERIMENT] ?: false -} - -private fun Preferences.getEnableVibrations(): Boolean { - return this[PrefsKeys.ENABLE_VIBRATIONS] ?: true -} - -private fun Preferences.getRadianMode(): Boolean { - return this[PrefsKeys.RADIAN_MODE] ?: true -} - -private fun Preferences.getSeparator(): Int { - return this[PrefsKeys.SEPARATOR] ?: Separator.SPACE -} - -private fun Preferences.getMiddleZero(): Boolean { - return this[PrefsKeys.MIDDLE_ZERO] ?: true -} - -private fun Preferences.getPartialHistoryView(): Boolean { - return this[PrefsKeys.PARTIAL_HISTORY_VIEW] ?: true -} - -private fun Preferences.getDigitsPrecision(): Int { - return this[PrefsKeys.DIGITS_PRECISION] ?: 3 -} - -private fun Preferences.getOutputFormat(): Int { - return this[PrefsKeys.OUTPUT_FORMAT] ?: OutputFormat.PLAIN -} - -private fun Preferences.getUnitConverterFormatTime(): Boolean { - return this[PrefsKeys.UNIT_CONVERTER_FORMAT_TIME] ?: false -} - -private fun Preferences.getUnitConverterSorting(): UnitsListSorting { - return this[PrefsKeys.UNIT_CONVERTER_SORTING] - ?.let { UnitsListSorting.valueOf(it) } ?: UnitsListSorting.USAGE -} - -private fun Preferences.getShownUnitGroups(): List { - return this[PrefsKeys.SHOWN_UNIT_GROUPS]?.letTryOrNull { list -> - list.ifEmpty { return@letTryOrNull listOf() }.split(",") - .map { UnitGroup.valueOf(it) } - } ?: ALL_UNIT_GROUPS -} - -private fun Preferences.getUnitConverterFavoritesOnly(): Boolean { - return this[PrefsKeys.UNIT_CONVERTER_FAVORITES_ONLY] - ?: false -} - -private fun Preferences.getLatestLeftSide(): String { - return this[PrefsKeys.LATEST_LEFT_SIDE] ?: MyUnitIDS.kilometer -} - -private fun Preferences.getLatestRightSide(): String { - return this[PrefsKeys.LATEST_RIGHT_SIDE] ?: MyUnitIDS.mile -} - -private fun Preferences.getAcButton(): Boolean { - return this[PrefsKeys.AC_BUTTON] ?: true -} - -private fun Preferences.getClearInputAfterEquals(): Boolean { - return this[PrefsKeys.CLEAR_INPUT_AFTER_EQUALS] ?: true -} - -private fun Preferences.getRpnMode(): Boolean { - return this[PrefsKeys.RPN_MODE] ?: false -} - -private inline fun T.letTryOrNull(block: (T) -> R): R? = try { - this?.let(block) -} catch (e: Exception) { - null -} diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index 7624c35e..85d45651 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -33,6 +33,7 @@ dependencies { implementation(libs.org.burnoutcrew.composereorderable.reorderable) implementation(libs.androidx.appcompat.appcompat) + implementation(project(":data:backup")) implementation(project(":data:common")) implementation(project(":data:database")) implementation(project(":data:model")) diff --git a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsScreen.kt b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsScreen.kt index 9c3ef599..6ae82b9c 100644 --- a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsScreen.kt @@ -18,12 +18,21 @@ package com.sadellie.unitto.feature.settings +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -34,16 +43,27 @@ import androidx.compose.material.icons.filled.Cached import androidx.compose.material.icons.filled.Calculate import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.RateReview import androidx.compose.material.icons.filled.SwapHoriz import androidx.compose.material.icons.filled.Vibration import androidx.compose.material.icons.filled._123 +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.Scaffold +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect 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 @@ -59,8 +79,6 @@ import com.sadellie.unitto.core.ui.common.UnittoListItem import com.sadellie.unitto.core.ui.common.UnittoScreenWithLargeTopBar import com.sadellie.unitto.core.ui.openLink import com.sadellie.unitto.core.ui.showToast -import com.sadellie.unitto.data.model.userprefs.GeneralPreferences -import com.sadellie.unitto.data.userprefs.GeneralPreferencesImpl import com.sadellie.unitto.feature.settings.navigation.aboutRoute import com.sadellie.unitto.feature.settings.navigation.calculatorSettingsRoute import com.sadellie.unitto.feature.settings.navigation.converterSettingsRoute @@ -74,38 +92,99 @@ internal fun SettingsRoute( navigateUp: () -> Unit, navControllerAction: (String) -> Unit, ) { - val userPrefs = viewModel.userPrefs.collectAsStateWithLifecycle().value - val cachePercentage = viewModel.cachePercentage.collectAsStateWithLifecycle() + val mContext = LocalContext.current - when (userPrefs) { - null -> UnittoEmptyScreen() - else -> { - SettingsScreen( - userPrefs = userPrefs, + val errorLabel = stringResource(R.string.error_label) + + val uiState: SettingsUIState = viewModel.uiState.collectAsStateWithLifecycle().value + val backupFileUri: Uri? = viewModel.backupFileUri.collectAsStateWithLifecycle(initialValue = null).value + val showErrorToast: Boolean = viewModel.showErrorToast.collectAsStateWithLifecycle(initialValue = false).value + + // Share backup file when it's emitted + LaunchedEffect(backupFileUri) { + if (backupFileUri == null) return@LaunchedEffect + mContext.share(backupFileUri) + } + + LaunchedEffect(showErrorToast) { + if (showErrorToast) Toast.makeText(mContext, errorLabel, Toast.LENGTH_SHORT).show() + } + + Crossfade(targetState = uiState) { state -> + when (state) { + SettingsUIState.Loading -> UnittoEmptyScreen() + + SettingsUIState.BackupInProgress -> BackingUpScreen() + + is SettingsUIState.Ready -> SettingsScreen( + uiState = state, navigateUp = navigateUp, navControllerAction = navControllerAction, updateVibrations = viewModel::updateVibrations, - cachePercentage = cachePercentage.value, clearCache = viewModel::clearCache, + backup = viewModel::backup, + restore = viewModel::restore ) } } } +@Composable +private fun BackingUpScreen() { + Scaffold { padding -> + Box( + modifier = Modifier + .padding(padding) + .fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + + BackHandler {} +} + @Composable private fun SettingsScreen( - userPrefs: GeneralPreferences, + uiState: SettingsUIState.Ready, navigateUp: () -> Unit, navControllerAction: (String) -> Unit, updateVibrations: (Boolean) -> Unit, - cachePercentage: Float, clearCache: () -> Unit, + backup: () -> Unit, + restore: (Uri) -> Unit = {}, ) { val mContext = LocalContext.current + var showMenu by remember { mutableStateOf(false) } + + // Pass picked file uri to BackupManager + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { pickedUri -> + if (pickedUri != null) restore(pickedUri) + } UnittoScreenWithLargeTopBar( title = stringResource(R.string.settings_title), - navigationIcon = { NavigateUpButton(navigateUp) } + navigationIcon = { NavigateUpButton(navigateUp) }, + actions = { + IconButton( + onClick = { showMenu = !showMenu }, + content = { Icon(Icons.Default.MoreVert, null) } + ) + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false } + ) { + DropdownMenuItem( + onClick = backup, + text = { Text("Backup") } + ) + DropdownMenuItem( + onClick = { launcher.launch(arrayOf(backupMimeType)) }, + text = { Text("Restore") } + ) + } + } ) { padding -> Column( modifier = Modifier @@ -155,12 +234,12 @@ private fun SettingsScreen( headlineText = stringResource(R.string.settings_vibrations), supportingText = stringResource(R.string.settings_vibrations_support), modifier = Modifier.clickable { navControllerAction(converterSettingsRoute) }, - switchState = userPrefs.enableVibrations, + switchState = uiState.enableVibrations, onSwitchChange = updateVibrations ) AnimatedVisibility( - visible = cachePercentage > 0, + visible = uiState.cacheSize > 0, enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut(), ) { @@ -189,19 +268,38 @@ private fun SettingsScreen( } } +private fun Context.share(uri: Uri) { + val shareIntent = Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_STREAM, uri) + type = backupMimeType // This is a fucking war crime, it should be text/json + } + + startActivity(shareIntent) +} + +private const val backupMimeType = "application/octet-stream" + @Preview @Composable private fun PreviewSettingsScreen() { var cacheSize by remember { mutableFloatStateOf(0.9f) } SettingsScreen( - userPrefs = GeneralPreferencesImpl( - enableVibrations = true + uiState = SettingsUIState.Ready( + enableVibrations = false, + cacheSize = 2, ), navigateUp = {}, navControllerAction = {}, updateVibrations = {}, - cachePercentage = cacheSize, - clearCache = { cacheSize = 0f } + clearCache = { cacheSize = 0f }, + backup = {} ) } + +@Preview +@Composable +private fun PreviewBackingUpScreen() { + BackingUpScreen() +} diff --git a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsUIState.kt b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsUIState.kt new file mode 100644 index 00000000..fa353bd9 --- /dev/null +++ b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsUIState.kt @@ -0,0 +1,30 @@ +/* + * Unitto is a unit converter for Android + * Copyright (c) 2023 Elshan Agaev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.sadellie.unitto.feature.settings + +internal sealed class SettingsUIState { + data object Loading : SettingsUIState() + + data object BackupInProgress : SettingsUIState() + + data class Ready( + val enableVibrations: Boolean, + val cacheSize: Int, + ) : SettingsUIState() +} diff --git a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsViewModel.kt b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsViewModel.kt index 3ec2cf04..b82234f4 100644 --- a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsViewModel.kt +++ b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsViewModel.kt @@ -18,14 +18,22 @@ package com.sadellie.unitto.feature.settings +import android.net.Uri +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.sadellie.unitto.data.backup.BackupManager import com.sadellie.unitto.data.common.stateIn import com.sadellie.unitto.data.database.CurrencyRatesDao import com.sadellie.unitto.data.model.repository.UserPreferencesRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @@ -33,15 +41,61 @@ import javax.inject.Inject internal class SettingsViewModel @Inject constructor( private val userPrefsRepository: UserPreferencesRepository, private val currencyRatesDao: CurrencyRatesDao, + private val backupManager: BackupManager ) : ViewModel() { - val userPrefs = userPrefsRepository.generalPrefs - .stateIn(viewModelScope, null) + private val _backupFileUri = MutableSharedFlow() + val backupFileUri = _backupFileUri.asSharedFlow() - val cachePercentage = currencyRatesDao.size() - .map { - (it / 100_000f).coerceIn(0f, 1f) + private val _showErrorToast = MutableSharedFlow() + val showErrorToast = _showErrorToast.asSharedFlow() + + private val _operation = MutableStateFlow(false) + private var backupJob: Job? = null + + val uiState = combine( + userPrefsRepository.generalPrefs, + currencyRatesDao.size(), + _operation, + ) { prefs, cacheSize, operation -> + if (operation) return@combine SettingsUIState.BackupInProgress + + SettingsUIState.Ready( + enableVibrations = prefs.enableVibrations, + cacheSize = cacheSize, + ) + } + .stateIn(viewModelScope, SettingsUIState.Loading) + + fun backup() { + backupJob?.cancel() + backupJob = viewModelScope.launch(Dispatchers.IO) { + _operation.update { true } + try { + val backupFileUri = backupManager.backup() + _backupFileUri.emit(backupFileUri) // Emit to trigger file share intent + _showErrorToast.emit(false) + } catch (e: Exception) { + _showErrorToast.emit(true) + Log.e(TAG, "$e") + } + _operation.update { false } } - .stateIn(viewModelScope, 0f) + } + + fun restore(uri: Uri) { + backupJob?.cancel() + backupJob = viewModelScope.launch(Dispatchers.IO) { + _operation.update { true } + try { + backupManager.restore(uri) + _showErrorToast.emit(false) + } catch (e: Exception) { + _showErrorToast.emit(true) + Log.e(TAG, "$e") + } + _operation.update { false } + } + } /** * @see UserPreferencesRepository.updateVibrations @@ -54,3 +108,5 @@ internal class SettingsViewModel @Inject constructor( currencyRatesDao.clear() } } + +private const val TAG = "SettingsViewModel" \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index d0e2a867..be9e2822 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -34,3 +34,4 @@ include(":data:model") include(":data:common") include(":data:evaluatto") include(":data:timezone") +include(":data:backup")