diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
index 251f6f1..b268ef3 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 0000000..4604c44
--- /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 dad8a20..0a06244 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 0000000..5f0e651
--- /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 0000000..2bbd683
--- /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 56a570f..32fe1b0 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 a117431..c06ad31 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 39dd899..1c008cf 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 9c25a18..fecb5ab 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 0000000..eccc9bc
--- /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 e932985..25b3640 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 fc16222..9a6c728 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 3be7680..ebc91d0 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 dcd5956..c8b587b 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 978eee4..a7134c8 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 90af9a3..db4e6ff 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 455c689..a786527 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 2964e75..2b15065 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!