From ea87b7bbae9472a6f4aa93f0f46fa74b8e51d2e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C9=91rry=20Shiv=C9=91m?= Date: Tue, 13 Aug 2024 15:00:14 +0530 Subject: [PATCH] feat: Add ability to import & export backups in CSV format (#154) Commits: * Add CSV backend & seperate JSON and CSV logic from backup manager * Add frontend for selecting backup filetype & rewrite CSV converter --------- Signed-off-by: starry-shivam --- .idea/deploymentTargetSelector.xml | 8 - .idea/other.xml | 252 ++++++++++++++++++ .../starry/greenstash/backup/BackupManager.kt | 140 ++++++---- .../greenstash/backup/GoalToCSVConverter.kt | 199 ++++++++++++++ .../greenstash/backup/GoalToJSONConverter.kt | 78 ++++++ .../greenstash/database/goal/GoalDao.kt | 2 +- .../starry/greenstash/ui/common/TipCard.kt | 50 +++- .../ui/screens/backups/BackupViewModel.kt | 15 +- .../backups/composables/BackupScreen.kt | 207 +++++++++++++- app/src/main/res/drawable/ic_backup_csv.xml | 28 ++ app/src/main/res/values-es/strings.xml | 6 +- app/src/main/res/values-it/strings.xml | 6 +- app/src/main/res/values-pt/strings.xml | 6 +- app/src/main/res/values-ru/strings.xml | 6 +- app/src/main/res/values-tr/strings.xml | 6 +- app/src/main/res/values-zh-rCN/strings.xml | 6 +- app/src/main/res/values-zh-rTW/strings.xml | 6 +- app/src/main/res/values/strings.xml | 6 +- 18 files changed, 935 insertions(+), 92 deletions(-) create mode 100644 .idea/other.xml create mode 100644 app/src/main/java/com/starry/greenstash/backup/GoalToCSVConverter.kt create mode 100644 app/src/main/java/com/starry/greenstash/backup/GoalToJSONConverter.kt create mode 100644 app/src/main/res/drawable/ic_backup_csv.xml diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index 251f6f11..b268ef36 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -4,14 +4,6 @@ diff --git a/.idea/other.xml b/.idea/other.xml new file mode 100644 index 00000000..4604c446 --- /dev/null +++ b/.idea/other.xml @@ -0,0 +1,252 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/starry/greenstash/backup/BackupManager.kt b/app/src/main/java/com/starry/greenstash/backup/BackupManager.kt index dad8a20f..0a06244a 100644 --- a/app/src/main/java/com/starry/greenstash/backup/BackupManager.kt +++ b/app/src/main/java/com/starry/greenstash/backup/BackupManager.kt @@ -27,20 +27,19 @@ package com.starry.greenstash.backup import android.content.Context import android.content.Intent -import android.graphics.Bitmap import android.util.Log -import androidx.annotation.Keep import androidx.core.content.FileProvider -import com.google.gson.Gson -import com.google.gson.GsonBuilder import com.starry.greenstash.BuildConfig -import com.starry.greenstash.database.core.GoalWithTransactions +import com.starry.greenstash.backup.BackupType.CSV +import com.starry.greenstash.backup.BackupType.JSON import com.starry.greenstash.database.goal.GoalDao import com.starry.greenstash.utils.updateText import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File import java.time.LocalDateTime +import java.util.Locale +import java.util.UUID /** * Handles all backup & restore related functionalities. @@ -51,43 +50,18 @@ import java.time.LocalDateTime */ class BackupManager(private val context: Context, private val goalDao: GoalDao) { - /** - * Instance of [Gson] with custom type adaptor applied for serializing - * and deserializing [Bitmap] fields. - */ - private val gsonInstance = GsonBuilder() - .registerTypeAdapter(Bitmap::class.java, BitmapTypeAdapter()) - .setDateFormat(ISO8601_DATE_FORMAT) - .create() - companion object { - /** Backup schema version. */ - const val BACKUP_SCHEMA_VERSION = 1 /** Authority for using file provider API. */ private const val FILE_PROVIDER_AUTHORITY = "${BuildConfig.APPLICATION_ID}.provider" - /** An ISO-8601 date format for Gson */ - private const val ISO8601_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSSZ" - /** Backup folder name inside cache directory. */ private const val BACKUP_FOLDER_NAME = "backups" } - /** - * Model for backup json data, containing current schema version - * and timestamp when backup was created. - * - * @param version backup schema version. - * @param timestamp timestamp when backup was created. - * @param data list of [GoalWithTransactions] to be backed up. - */ - @Keep - data class BackupJsonModel( - val version: Int = BACKUP_SCHEMA_VERSION, - val timestamp: Long, - val data: List - ) + // Converters for different backup types. + private val goalToJsonConverter = GoalToJSONConverter() + private val goalToCsvConverter = GoalToCSVConverter() /** * Logger function with pre-applied tag. @@ -103,51 +77,64 @@ class BackupManager(private val context: Context, private val goalDao: GoalDao) * * @return a chooser [Intent] for newly created backup file. */ - suspend fun createDatabaseBackup(): Intent = withContext(Dispatchers.IO) { - log("Fetching goals from database and serialising into json...") + suspend fun createDatabaseBackup(backupType: BackupType): Intent = withContext(Dispatchers.IO) { + log("Fetching goals from database and serialising into ${backupType.name}...") val goalsWithTransactions = goalDao.getAllGoals() - val jsonString = gsonInstance.toJson( - BackupJsonModel( - timestamp = System.currentTimeMillis(), - data = goalsWithTransactions - ) - ) - - log("Creating backup json file inside cache directory...") - val fileName = "GreenStash-Backup(${System.currentTimeMillis()}).json" + val backupString = when (backupType) { + BackupType.JSON -> goalToJsonConverter.convertToJson(goalsWithTransactions) + BackupType.CSV -> goalToCsvConverter.convertToCSV(goalsWithTransactions) + } + + log("Creating a ${backupType.name} file inside cache directory...") + val fileName = "GreenStash-(${UUID.randomUUID()}).${backupType.name.lowercase(Locale.US)}" val file = File(File(context.cacheDir, BACKUP_FOLDER_NAME).apply { mkdir() }, fileName) - file.updateText(jsonString) + file.updateText(backupString) val uri = FileProvider.getUriForFile(context, FILE_PROVIDER_AUTHORITY, file) log("Building and returning chooser intent for backup file.") + val intentType = when (backupType) { + BackupType.JSON -> "application/json" + BackupType.CSV -> "text/csv" + } return@withContext Intent(Intent.ACTION_SEND).apply { - type = "application/json" + type = intentType addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) putExtra(Intent.EXTRA_STREAM, uri) putExtra(Intent.EXTRA_SUBJECT, "Greenstash Backup") putExtra(Intent.EXTRA_TEXT, "Created at ${LocalDateTime.now()}") }.let { intent -> Intent.createChooser(intent, fileName) } - } /** - * Restores a database backup by deserializing the backup json string + * Restores a database backup by deserializing the backup json or csv string * and saving goals and transactions back into the database. * - * @param jsonString a valid backup json as sting. + * @param backupString a valid backup json or csv string. * @param onFailure callback to be called if [BackupManager] failed parse the json string. * @param onSuccess callback to be called after backup was successfully restored. */ suspend fun restoreDatabaseBackup( - jsonString: String, + backupString: String, + backupType: BackupType = BackupType.JSON, onFailure: () -> Unit, - onSuccess: () -> Unit + onSuccess: () -> Unit, ) = withContext(Dispatchers.IO) { + log("Parsing backup file...") + when (backupType) { + BackupType.JSON -> restoreJsonBackup(backupString, onFailure, onSuccess) + BackupType.CSV -> restoreCsvBackup(backupString, onFailure, onSuccess) + } + } - // Parse json string. - log("Parsing backup json file...") - val backupData: BackupJsonModel? = try { - gsonInstance.fromJson(jsonString, BackupJsonModel::class.java) + // Restores json backup by converting json string into [BackupJsonModel] and + // then inserting goals and transactions into the database. + private suspend fun restoreJsonBackup( + backupString: String, + onFailure: () -> Unit, + onSuccess: () -> Unit + ) { + val backupData = try { + goalToJsonConverter.convertFromJson(backupString) } catch (exc: Exception) { log("Failed to parse backup json file! Err: ${exc.message}") exc.printStackTrace() @@ -156,12 +143,47 @@ class BackupManager(private val context: Context, private val goalDao: GoalDao) if (backupData?.data == null) { withContext(Dispatchers.Main) { onFailure() } - return@withContext + return + } + + log("Inserting goals & transactions into the database...") + goalDao.insertGoalWithTransactions(backupData.data) + withContext(Dispatchers.Main) { onSuccess() } + } + + // Restores csv backup by converting csv string into [GoalWithTransactions] list. + private suspend fun restoreCsvBackup( + backupString: String, + onFailure: () -> Unit, + onSuccess: () -> Unit + ) { + val backupData = try { + goalToCsvConverter.convertFromCSV(backupString) + } catch (exc: Exception) { + log("Failed to parse backup csv file! Err: ${exc.message}") + exc.printStackTrace() + null + } + + if (backupData?.data == null) { + withContext(Dispatchers.Main) { onFailure() } + return } - // Insert goal & transaction data into database. log("Inserting goals & transactions into the database...") - goalDao.insertGoalWithTransaction(backupData.data) + goalDao.insertGoalWithTransactions(backupData.data) withContext(Dispatchers.Main) { onSuccess() } } -} \ No newline at end of file + +} + + +/** + * Type of backup file. + * + * @property JSON for JSON backup file. + * @property CSV for CSV backup file. + * + * @see [BackupManager] + */ +enum class BackupType { JSON, CSV } \ No newline at end of file diff --git a/app/src/main/java/com/starry/greenstash/backup/GoalToCSVConverter.kt b/app/src/main/java/com/starry/greenstash/backup/GoalToCSVConverter.kt new file mode 100644 index 00000000..5f0e6514 --- /dev/null +++ b/app/src/main/java/com/starry/greenstash/backup/GoalToCSVConverter.kt @@ -0,0 +1,199 @@ +/** + * MIT License + * + * Copyright (c) [2022 - Present] Stɑrry Shivɑm + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + + +package com.starry.greenstash.backup + +import androidx.annotation.Keep +import com.starry.greenstash.database.core.GoalWithTransactions +import com.starry.greenstash.database.goal.Goal +import com.starry.greenstash.database.goal.GoalPriority +import com.starry.greenstash.database.transaction.Transaction +import com.starry.greenstash.database.transaction.TransactionType +import java.io.StringWriter + +/** + * Converts [GoalWithTransactions] data to CSV format and vice versa. + */ +class GoalToCSVConverter { + + companion object { + /** Backup schema version. */ + const val BACKUP_SCHEMA_VERSION = 1 + + /** CSV delimiter. */ + const val CSV_DELIMITER = "," + } + + /** + * Model for backup CSV data, containing current schema version + * and timestamp when backup was created. + * + * @param version backup schema version. + * @param timestamp timestamp when backup was created. + * @param data list of [GoalWithTransactions] to be backed up. + */ + @Keep + data class BackupCSVModel( + val version: Int = BACKUP_SCHEMA_VERSION, + val timestamp: Long, + val data: List + ) + + /** + * Converts the given [GoalWithTransactions] list into a CSV string. + * + * @param goalWithTransactions List of [GoalWithTransactions] to convert to CSV. + * @return A CSV-formatted string. + */ + fun convertToCSV(goalWithTransactions: List): String { + val writer = StringWriter() + writer.appendLine("Schema Version,$BACKUP_SCHEMA_VERSION") + writer.appendLine("Timestamp,${System.currentTimeMillis()}") + writer.appendLine( + // Goal columns + "Goal ID," + + "Title," + + "Target Amount," + + "Deadline," + + "Priority," + + "Reminder," + + "Goal Icon ID," + + "Archived," + + "Additional Notes," + + // Transaction columns + "Transaction ID," + + "Type," + + "Timestamp," + + "Amount," + + "Notes" + ) + goalWithTransactions.forEach { goalWithTransaction -> + val goal = goalWithTransaction.goal + val transactions = goalWithTransaction.transactions + + println("Goal: ${goal.title}") + println("Transactions: ${transactions.size}") + + if (transactions.isEmpty()) { + writer.appendLine( + listOf( + goal.goalId, + goal.title, + goal.targetAmount, + goal.deadline, + goal.priority.name, + goal.reminder, + goal.goalIconId ?: "", + goal.archived, + goal.additionalNotes, + "", + "", + "", + "", + "" + ).joinToString(separator = CSV_DELIMITER) + ) + } else { + transactions.forEach { transaction -> + writer.appendLine( + listOf( + goal.goalId, + goal.title, + goal.targetAmount, + goal.deadline, + goal.priority.name, + goal.reminder, + goal.goalIconId ?: "", + goal.archived, + goal.additionalNotes, + transaction.transactionId, + transaction.type.name, + transaction.timeStamp, + transaction.amount, + transaction.notes + ).joinToString(separator = CSV_DELIMITER) + ) + } + } + } + + return writer.toString() + } + + /** + * Converts a CSV string back into a list of [GoalWithTransactions]. + * + * @param csv The CSV string to convert. + * @return A [BackupCSVModel] containing the version, timestamp, and data. + */ + fun convertFromCSV(csv: String): BackupCSVModel { + val lines = csv.lines() + val version = lines[0].split(CSV_DELIMITER)[1].toInt() + val timestamp = lines[1].split(CSV_DELIMITER)[1].toLong() + val data = mutableListOf() + + lines.drop(3).forEach { line -> + if (line.isBlank()) return@forEach + val columns = line.split(CSV_DELIMITER) + + val goal = Goal( + title = columns[1], + targetAmount = columns[2].toDouble(), + deadline = columns[3], + priority = GoalPriority.valueOf(columns[4]), + reminder = columns[5].toBoolean(), + goalIconId = columns[6].ifEmpty { null }, + archived = columns[7].toBoolean(), + additionalNotes = columns[8], + goalImage = null + ).apply { goalId = columns[0].toLong() } + + val transaction = if (columns[9].isNotEmpty()) { + Transaction( + ownerGoalId = columns[0].toLong(), + type = TransactionType.valueOf(columns[10]), + timeStamp = columns[11].toLong(), + amount = columns[12].toDouble(), + notes = columns[13] + ).apply { transactionId = columns[9].toLong() } + } else null + + val existingGoalWithTransactions = data.find { it.goal.goalId == goal.goalId } + if (existingGoalWithTransactions != null && transaction != null) { + val mutableTransactions = existingGoalWithTransactions.transactions.toMutableList() + mutableTransactions.add(transaction) + data[data.indexOf(existingGoalWithTransactions)] = + GoalWithTransactions( + goal = existingGoalWithTransactions.goal, + transactions = mutableTransactions + ) + } else { + data.add(GoalWithTransactions(goal, transaction?.let { listOf(it) } ?: emptyList())) + } + } + + return BackupCSVModel(version, timestamp, data) + } +} diff --git a/app/src/main/java/com/starry/greenstash/backup/GoalToJSONConverter.kt b/app/src/main/java/com/starry/greenstash/backup/GoalToJSONConverter.kt new file mode 100644 index 00000000..2bbd6836 --- /dev/null +++ b/app/src/main/java/com/starry/greenstash/backup/GoalToJSONConverter.kt @@ -0,0 +1,78 @@ +/** + * MIT License + * + * Copyright (c) [2022 - Present] Stɑrry Shivɑm + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + + +package com.starry.greenstash.backup + +import android.graphics.Bitmap +import androidx.annotation.Keep +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.starry.greenstash.database.core.GoalWithTransactions + +/** + * Converts [GoalWithTransactions] data to JSON format and vice versa. + */ +class GoalToJSONConverter { + + /** + * Instance of [Gson] with custom type adaptor applied for serializing + * and deserializing [Bitmap] fields. + */ + private val gsonInstance = GsonBuilder() + .registerTypeAdapter(Bitmap::class.java, BitmapTypeAdapter()) + .setDateFormat(ISO8601_DATE_FORMAT) + .create() + + companion object { + /** Backup schema version. */ + const val BACKUP_SCHEMA_VERSION = 1 + + /** An ISO-8601 date format for Gson */ + private const val ISO8601_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSSZ" + } + + /** + * Model for backup json data, containing current schema version + * and timestamp when backup was created. + * + * @param version backup schema version. + * @param timestamp timestamp when backup was created. + * @param data list of [GoalWithTransactions] to be backed up. + */ + @Keep + data class BackupJsonModel( + val version: Int = BACKUP_SCHEMA_VERSION, + val timestamp: Long, + val data: List + ) + + fun convertToJson(goalWithTransactions: List): String = + gsonInstance.toJson( + BackupJsonModel(timestamp = System.currentTimeMillis(), data = goalWithTransactions) + ) + + fun convertFromJson(json: String): BackupJsonModel = + gsonInstance.fromJson(json, BackupJsonModel::class.java) +} \ No newline at end of file diff --git a/app/src/main/java/com/starry/greenstash/database/goal/GoalDao.kt b/app/src/main/java/com/starry/greenstash/database/goal/GoalDao.kt index 56a570fd..32fe1b0f 100644 --- a/app/src/main/java/com/starry/greenstash/database/goal/GoalDao.kt +++ b/app/src/main/java/com/starry/greenstash/database/goal/GoalDao.kt @@ -53,7 +53,7 @@ interface GoalDao { * @param goalsWithTransactions List of GoalWithTransactions. */ @Transaction - suspend fun insertGoalWithTransaction(goalsWithTransactions: List) { + suspend fun insertGoalWithTransactions(goalsWithTransactions: List) { goalsWithTransactions.forEach { goalWithTransactions -> // Set placeholder id. goalWithTransactions.goal.goalId = 0L diff --git a/app/src/main/java/com/starry/greenstash/ui/common/TipCard.kt b/app/src/main/java/com/starry/greenstash/ui/common/TipCard.kt index a117431e..c06ad31b 100644 --- a/app/src/main/java/com/starry/greenstash/ui/common/TipCard.kt +++ b/app/src/main/java/com/starry/greenstash/ui/common/TipCard.kt @@ -54,6 +54,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.starry.greenstash.ui.theme.greenstashFont + @Composable fun TipCard( modifier: Modifier = Modifier, @@ -61,6 +62,43 @@ fun TipCard( description: String, showTipCard: Boolean, onDismissRequest: () -> Unit +) { + TipCard( + modifier = modifier, + icon = icon, + description = description, + showTipCard = showTipCard, + showDismissButton = true, + onDismissRequest = onDismissRequest + ) +} + + +@Composable +fun TipCardNoDismiss( + modifier: Modifier = Modifier, + icon: ImageVector = Icons.Filled.Lightbulb, + description: String, + showTipCard: Boolean, +) { + TipCard( + modifier = modifier, + icon = icon, + description = description, + showTipCard = showTipCard, + showDismissButton = false, + onDismissRequest = {} + ) +} + +@Composable +private fun TipCard( + modifier: Modifier = Modifier, + icon: ImageVector = Icons.Filled.Lightbulb, + description: String, + showTipCard: Boolean, + showDismissButton: Boolean = true, + onDismissRequest: () -> Unit ) { Column( modifier = Modifier @@ -102,11 +140,13 @@ fun TipCard( ) } Spacer(modifier = Modifier.height(16.dp)) - Button( - onClick = { onDismissRequest() }, - modifier = Modifier.align(Alignment.End) - ) { - Text(text = "OK") + if (showDismissButton) { + Button( + onClick = { onDismissRequest() }, + modifier = Modifier.align(Alignment.End) + ) { + Text(text = "OK") + } } } } diff --git a/app/src/main/java/com/starry/greenstash/ui/screens/backups/BackupViewModel.kt b/app/src/main/java/com/starry/greenstash/ui/screens/backups/BackupViewModel.kt index 39dd899b..1c008cff 100644 --- a/app/src/main/java/com/starry/greenstash/ui/screens/backups/BackupViewModel.kt +++ b/app/src/main/java/com/starry/greenstash/ui/screens/backups/BackupViewModel.kt @@ -4,6 +4,7 @@ import android.content.Intent import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.starry.greenstash.backup.BackupManager +import com.starry.greenstash.backup.BackupType import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -15,17 +16,23 @@ class BackupViewModel @Inject constructor( private val backupManager: BackupManager ) : ViewModel() { - fun takeBackup(onComplete: (Intent) -> Unit) { + fun takeBackup(backupType: BackupType, onComplete: (Intent) -> Unit) { viewModelScope.launch(Dispatchers.IO) { - val backupIntent = backupManager.createDatabaseBackup() + val backupIntent = backupManager.createDatabaseBackup(backupType) withContext(Dispatchers.Main) { onComplete(backupIntent) } } } - fun restoreBackup(jsonString: String, onSuccess: () -> Unit, onFailure: () -> Unit) { + fun restoreBackup( + backupType: BackupType, + backupString: String, + onSuccess: () -> Unit, + onFailure: () -> Unit + ) { viewModelScope.launch(Dispatchers.IO) { backupManager.restoreDatabaseBackup( - jsonString = jsonString, + backupType = backupType, + backupString = backupString, onSuccess = onSuccess, onFailure = onFailure ) diff --git a/app/src/main/java/com/starry/greenstash/ui/screens/backups/composables/BackupScreen.kt b/app/src/main/java/com/starry/greenstash/ui/screens/backups/composables/BackupScreen.kt index 9c25a185..fecb5ab8 100644 --- a/app/src/main/java/com/starry/greenstash/ui/screens/backups/composables/BackupScreen.kt +++ b/app/src/main/java/com/starry/greenstash/ui/screens/backups/composables/BackupScreen.kt @@ -42,6 +42,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api @@ -50,32 +52,44 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import coil.compose.AsyncImage import com.starry.greenstash.R +import com.starry.greenstash.backup.BackupType +import com.starry.greenstash.ui.common.TipCardNoDismiss import com.starry.greenstash.ui.screens.backups.BackupViewModel import com.starry.greenstash.ui.theme.greenstashFont import com.starry.greenstash.utils.weakHapticFeedback +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.io.InputStreamReader import java.io.Reader @@ -123,7 +137,11 @@ fun BackupScreen(navController: NavController) { ) ) }, content = { - val backupLauncher = + val ftpButtonText = remember { mutableStateOf("") } + val selectedBackupType = remember { mutableStateOf(BackupType.JSON) } + val showFileTypePicker = remember { mutableStateOf(false) } + + val backupRestoreLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> uri?.let { fileUri -> context.contentResolver.openInputStream(fileUri)?.let { ips -> @@ -139,7 +157,9 @@ fun BackupScreen(navController: NavController) { out.appendRange(buffer, 0, numRead) } - viewModel.restoreBackup(jsonString = out.toString(), + viewModel.restoreBackup( + backupType = selectedBackupType.value, + backupString = out.toString(), onSuccess = { coroutineScope.launch { snackBarHostState.showSnackbar(context.getString(R.string.backup_restore_success)) @@ -156,9 +176,56 @@ fun BackupScreen(navController: NavController) { } + + if (showFileTypePicker.value) { + BackupFileTypePicker( + showFileTypePicker = showFileTypePicker, + buttonText = ftpButtonText.value, + onConfirm = { backupType -> + // Used for restoring backup inside file picker launcher. + selectedBackupType.value = backupType + + if (ftpButtonText.value.isEmpty()) { + return@BackupFileTypePicker + } + + if (ftpButtonText.value == context.getString(R.string.backup_ftp_create_button)) { + viewModel.takeBackup(backupType) { intent -> + context.startActivity(intent) + } + } else { + // Restore backup + when (backupType) { + BackupType.JSON -> { + backupRestoreLauncher.launch(arrayOf("application/json")) + } + + BackupType.CSV -> { + backupRestoreLauncher.launch( + arrayOf( + "text/csv", + "text/comma-separated-values", + "application/vnd.ms-excel" + ) + ) + } + } + } + } + ) + } + BackupScreenContent(paddingValues = it, - onBackupClicked = { viewModel.takeBackup { intent -> context.startActivity(intent) } }, - onRestoreClicked = { backupLauncher.launch(arrayOf("application/json")) } + onBackupClicked = { + ftpButtonText.value = + context.getString(R.string.backup_ftp_create_button) + showFileTypePicker.value = true + }, + onRestoreClicked = { + ftpButtonText.value = + context.getString(R.string.backup_ftp_restore_button) + showFileTypePicker.value = true + } ) }) } @@ -222,7 +289,6 @@ private fun BackupScreenContent( Spacer(modifier = Modifier.height(14.dp)) - Row( modifier = Modifier.fillMaxWidth() ) { @@ -249,10 +315,137 @@ private fun BackupScreenContent( shape = RoundedCornerShape(12.dp), ) { Text( - text = stringResource(id = R.string.restore_button), + text = stringResource(id = R.string.backup_restore_button), + fontFamily = greenstashFont + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun BackupFileTypePicker( + showFileTypePicker: MutableState, + buttonText: String, + onConfirm: (BackupType) -> Unit +) { + val sheetState = rememberModalBottomSheetState() + val coroutineScope = rememberCoroutineScope() + + ModalBottomSheet( + onDismissRequest = { + coroutineScope.launch { + sheetState.hide() + delay(300) + showFileTypePicker.value = false + } + }, + sheetState = sheetState + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp) + ) { + val (selectedBackupType, onBackupTypeSelected) = remember { + mutableStateOf(BackupType.JSON) + } + + Text( + text = stringResource(id = R.string.backup_select_file_type), + fontFamily = greenstashFont, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 6.dp) + ) + + SingleChoiceSegmentedButtonRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 18.dp, vertical = 6.dp) + ) { + SegmentedButton( + selected = selectedBackupType == BackupType.JSON, + onClick = { onBackupTypeSelected(BackupType.JSON) }, + shape = RoundedCornerShape(topStart = 14.dp, bottomStart = 14.dp), + label = { + Text( + text = BackupType.JSON.name, fontFamily = greenstashFont + ) + }, + icon = { + if (selectedBackupType == BackupType.JSON) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + } + }, + colors = SegmentedButtonDefaults.colors( + activeContentColor = MaterialTheme.colorScheme.onPrimary, + activeContainerColor = MaterialTheme.colorScheme.primary, + ) + ) + + SegmentedButton( + selected = selectedBackupType == BackupType.CSV, + onClick = { onBackupTypeSelected(BackupType.CSV) }, + shape = RoundedCornerShape(topEnd = 14.dp, bottomEnd = 14.dp), + label = { + Text( + text = BackupType.CSV.name, + fontFamily = greenstashFont + ) + }, + icon = { + if (selectedBackupType == BackupType.CSV) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + } + }, + colors = SegmentedButtonDefaults.colors( + activeContentColor = MaterialTheme.colorScheme.onPrimary, + activeContainerColor = MaterialTheme.colorScheme.primary, + ) + ) + } + + + Spacer(modifier = Modifier.height(4.dp)) + + TipCardNoDismiss( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), + icon = ImageVector.vectorResource(id = R.drawable.ic_backup_csv), + description = stringResource(id = R.string.backup_csv_note), + showTipCard = selectedBackupType == BackupType.CSV, + ) + + Button( + onClick = { + onConfirm(selectedBackupType) + coroutineScope.launch { + sheetState.hide() + delay(300) + showFileTypePicker.value = false + } + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + shape = RoundedCornerShape(14.dp), + ) { + Text( + text = buttonText, fontFamily = greenstashFont ) } + + Spacer(modifier = Modifier.height(12.dp)) } } -} \ No newline at end of file +} + diff --git a/app/src/main/res/drawable/ic_backup_csv.xml b/app/src/main/res/drawable/ic_backup_csv.xml new file mode 100644 index 00000000..eccc9bc4 --- /dev/null +++ b/app/src/main/res/drawable/ic_backup_csv.xml @@ -0,0 +1,28 @@ + + + + + + + + + diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index e9329852..25b3640c 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -153,7 +153,11 @@ Haz una copia de seguridad de tus datos incluyendo metas, progreso actual, transacciones, etc para recuperarlos cuando quieras. Nota: La copia de seguridad no incluye la configuración de la app. Respaldar - Restaurar + Restaurar + Seleccionar tipo de archivo de respaldo + Nota: Debido a las limitaciones del formato CSV, las imágenes de los objetivos no se respaldarán. + Crear Respaldo + Restaurar Respaldo ¡La copia de seguridad se ha restaurado correctamente! diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index fc162229..9a6c7283 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -152,7 +152,11 @@ Esegui il backup dei dati dell\'app, compresi tutti gli obiettivi di risparmio, i progressi attuali, le transazioni e così via, e ripristinali facilmente ogni volta che lo desideri. Ricordati che i backup non includono le impostazioni dell\'app. Fai il Backup - Ripristina + Ripristina + Seleziona il tipo di file di backup + Nota: A causa delle limitazioni del formato CSV, le immagini degli obiettivi non verranno salvate nel backup. + Crea Backup + Ripristina Backup Backup ripristinato con successo! diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 3be7680c..ebc91d01 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -151,7 +151,11 @@ Faça backup dos dados do aplicativo, incluindo todas as suas metas de economia, progresso atual, transações, etc., e restaure facilmente sempre que quiser. Observe que os backups não incluem configurações do aplicativo. Backup - Restaurar + Restaurar + Selecionar tipo de arquivo de backup + Nota: Devido às limitações do formato CSV, as imagens dos objetivos não serão incluídas no backup. + Criar Backup + Restaurar Backup Backup restaurado com sucesso! diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index dcd5956f..c8b587b4 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -153,7 +153,11 @@ Создайте резервную копию данных приложения, включая все цели накопления, текущий прогресс, транзакции и т. д., и легко восстановите ее в любое время. Обратите внимание, что резервные копии не включают настройки приложения. Резервное копирование - Восстановить + Восстановить + Выберите тип файла резервной копии + Примечание: Из-за ограничений формата CSV изображения целей не будут включены в резервную копию. + Создать резервную копию + Восстановить резервную копию Резервная копия успешно восстановлена! diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 978eee47..a7134c8b 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -154,7 +154,11 @@ Tüm tasarruf hedefleriniz, mevcut ilerlemeniz, işlemleriniz vb. dahil olmak üzere uygulama verilerini yedekleyin ve istediğiniz zaman kolayca geri yükleyin. Not: Yedekler uygulama ayarlarını içermez. Yedekle - Geri Yükle + Geri Yükle + Yedekleme dosya türünü seç + Not: CSV formatının sınırlamaları nedeniyle, hedef resimleri yedeklenmeyecektir. + Yedek Oluştur + Yedeği Geri Yükle Yedek başarıyla geri yüklendi! diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 90af9a37..db4e6ff1 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -152,7 +152,11 @@ 备份应用数据——包括您所有的省钱目标、当前进展、收支等等,并且可以随时轻松恢复。 注意:备份不包括应用设置信息。 备份 - 恢复 + 恢复 + 选择备份文件类型 + 注意:由于CSV格式的限制,目标图片将不会备份。 + 创建备份 + 恢复备份 备份成功恢复! diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 455c6891..a7865272 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -151,7 +151,11 @@ 備份應用程式資料,包括所有的儲蓄目標、目前進度、交易等,並在您需要時可以輕鬆還原。 請注意,備份不包括應用程式設定。 備份 - 還原 + 還原 + 選擇備份檔案類型 + 注意:由於CSV格式的限制,目標圖片將不會備份。 + 創建備份 + 恢復備份 備份已成功還原! diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2964e750..2b150657 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -152,7 +152,11 @@ Backup app data including all of your saving goals, current progress, transactions etc and easily restore it whenever you want. Please note that backups do not include app settings. Backup - Restore + Restore + Select backup file type + Note: Due to limitations of the CSV format, goal images will not be backed up. + Create Backup + Restore Backup Backup restored successfully!