diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index ad6e9f14f7..589b0be423 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -15,7 +15,7 @@ _Put an `x` in the boxes that apply._ ## Pull Request Type Please check the type of change your PR introduces: -Please check the type of change your PR introduces: + - [ ] Bugfix - [ ] Feature - [ ] Code style update (formatting, new lines, etc.) @@ -62,4 +62,4 @@ _Note: If you've checked "Compact Middle Packages" the option will appear as `co **Method 3: Fastlane** - Install Ruby 2.7 - `bundle install` -- `bundle exec fastlane ui_tests` \ No newline at end of file +- `bundle exec fastlane ui_tests` diff --git a/README.md b/README.md index fdbccdab6d..0a348d24f4 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -![Latest Release](https://img.shields.io/github/v/release/iliyangermanov/ivy-wallet) +[![Latest Release](https://img.shields.io/github/v/release/iliyangermanov/ivy-wallet)](https://github.com/ILIYANGERMANOV/ivy-wallet/releases) [![Lint](https://github.com/ILIYANGERMANOV/ivy-wallet/actions/workflows/lint.yml/badge.svg)](https://github.com/ILIYANGERMANOV/ivy-wallet/actions/workflows/lint.yml) [![Internal Release](https://github.com/ILIYANGERMANOV/ivy-wallet/actions/workflows/internal_release.yml/badge.svg)](https://github.com/ILIYANGERMANOV/ivy-wallet/actions/workflows/internal_release.yml) diff --git a/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/120.json b/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/120.json new file mode 100644 index 0000000000..84070e16b9 --- /dev/null +++ b/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/120.json @@ -0,0 +1,793 @@ +{ + "formatVersion": 1, + "database": { + "version": 120, + "identityHash": "751c82ed72a54493f42000cd47a99137", + "entities": [ + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `currency` TEXT, `color` INTEGER NOT NULL, `icon` TEXT, `orderNum` REAL NOT NULL, `includeInBalance` INTEGER NOT NULL, `seAccountId` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "includeInBalance", + "columnName": "includeInBalance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "seAccountId", + "columnName": "seAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `type` TEXT NOT NULL, `amount` REAL NOT NULL, `toAccountId` TEXT, `toAmount` REAL, `title` TEXT, `description` TEXT, `dateTime` INTEGER, `categoryId` TEXT, `dueDate` INTEGER, `recurringRuleId` TEXT, `attachmentUrl` TEXT, `loanId` TEXT, `loanRecordId` TEXT, `seTransactionId` TEXT, `seAutoCategoryId` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "toAccountId", + "columnName": "toAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "toAmount", + "columnName": "toAmount", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dateTime", + "columnName": "dateTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dueDate", + "columnName": "dueDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recurringRuleId", + "columnName": "recurringRuleId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attachmentUrl", + "columnName": "attachmentUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loanId", + "columnName": "loanId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loanRecordId", + "columnName": "loanRecordId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "seTransactionId", + "columnName": "seTransactionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "seAutoCategoryId", + "columnName": "seAutoCategoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "categories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `color` INTEGER NOT NULL, `icon` TEXT, `orderNum` REAL NOT NULL, `seCategoryName` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "seCategoryName", + "columnName": "seCategoryName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "wishlist_items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `price` REAL NOT NULL, `accountId` TEXT NOT NULL, `categoryId` TEXT, `description` TEXT, `plannedDateTime` INTEGER, `orderNum` REAL NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "price", + "columnName": "price", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "plannedDateTime", + "columnName": "plannedDateTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`theme` TEXT NOT NULL, `currency` TEXT NOT NULL, `bufferAmount` REAL NOT NULL, `name` TEXT NOT NULL, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bufferAmount", + "columnName": "bufferAmount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "planned_payment_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`startDate` INTEGER, `intervalN` INTEGER, `intervalType` TEXT, `oneTime` INTEGER NOT NULL, `type` TEXT NOT NULL, `accountId` TEXT NOT NULL, `amount` REAL NOT NULL, `categoryId` TEXT, `title` TEXT, `description` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "startDate", + "columnName": "startDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "intervalN", + "columnName": "intervalN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "intervalType", + "columnName": "intervalType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "oneTime", + "columnName": "oneTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `authProviderType` TEXT NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT, `profilePicture` TEXT, `color` INTEGER NOT NULL, `testUser` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authProviderType", + "columnName": "authProviderType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "firstName", + "columnName": "firstName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastName", + "columnName": "lastName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "profilePicture", + "columnName": "profilePicture", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "testUser", + "columnName": "testUser", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "exchange_rates", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseCurrency` TEXT NOT NULL, `currency` TEXT NOT NULL, `rate` REAL NOT NULL, PRIMARY KEY(`baseCurrency`, `currency`))", + "fields": [ + { + "fieldPath": "baseCurrency", + "columnName": "baseCurrency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "baseCurrency", + "currency" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "budgets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `amount` REAL NOT NULL, `categoryIdsSerialized` TEXT, `accountIdsSerialized` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `orderId` REAL NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "categoryIdsSerialized", + "columnName": "categoryIdsSerialized", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountIdsSerialized", + "columnName": "accountIdsSerialized", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orderId", + "columnName": "orderId", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "loans", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `amount` REAL NOT NULL, `type` TEXT NOT NULL, `color` INTEGER NOT NULL, `icon` TEXT, `orderNum` REAL NOT NULL, `accountId` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "loan_records", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`loanId` TEXT NOT NULL, `amount` REAL NOT NULL, `note` TEXT, `dateTime` INTEGER NOT NULL, `interest` INTEGER NOT NULL, `accountId` TEXT, `convertedAmount` REAL, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "loanId", + "columnName": "loanId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dateTime", + "columnName": "dateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "interest", + "columnName": "interest", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "convertedAmount", + "columnName": "convertedAmount", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '751c82ed72a54493f42000cd47a99137')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/ivy/wallet/compose/helpers/AccountModal.kt b/app/src/androidTest/java/com/ivy/wallet/compose/helpers/AccountModal.kt index 16ee9abf89..9972fa640e 100644 --- a/app/src/androidTest/java/com/ivy/wallet/compose/helpers/AccountModal.kt +++ b/app/src/androidTest/java/com/ivy/wallet/compose/helpers/AccountModal.kt @@ -43,7 +43,7 @@ class AccountModal( } fun tapIncludeInBalance() { - composeTestRule.onNodeWithText("Include in balance") + composeTestRule.onNodeWithText("Include account") .performClick() } } \ No newline at end of file diff --git a/app/src/main/java/com/ivy/wallet/Constants.kt b/app/src/main/java/com/ivy/wallet/Constants.kt index 02bb854faf..4272221fff 100644 --- a/app/src/main/java/com/ivy/wallet/Constants.kt +++ b/app/src/main/java/com/ivy/wallet/Constants.kt @@ -31,4 +31,6 @@ object Constants { const val USER_INACTIVITY_TIME_LIMIT = 60 //Time in seconds const val SWIPE_DOWN_THRESHOLD_OPEN_MORE_MENU = 200 + + const val SWIPE_UP_EXPANDED_THRESHOLD = 200 } \ No newline at end of file diff --git a/app/src/main/java/com/ivy/wallet/base/AmountFormatting.kt b/app/src/main/java/com/ivy/wallet/base/AmountFormatting.kt index 41065cf388..24be841bd2 100644 --- a/app/src/main/java/com/ivy/wallet/base/AmountFormatting.kt +++ b/app/src/main/java/com/ivy/wallet/base/AmountFormatting.kt @@ -157,7 +157,8 @@ fun Long.length() = when (this) { fun formatInputAmount( currency: String, amount: String, - newSymbol: String + newSymbol: String, + decimalCountMax:Int = 2, ): String? { val newlyEnteredNumberString = amount + newSymbol @@ -169,7 +170,7 @@ fun formatInputAmount( val amountDouble = newlyEnteredNumberString.amountToDoubleOrNull() val decimalCountOkay = IvyCurrency.fromCode(currency)?.isCrypto == true - || decimalCount <= 2 + || decimalCount <= decimalCountMax if (amountDouble != null && decimalCountOkay) { val intPart = truncate(amountDouble).toInt() val decimalPartFormatted = if (decimalPartString != null) { diff --git a/app/src/main/java/com/ivy/wallet/di/AppModule.kt b/app/src/main/java/com/ivy/wallet/di/AppModule.kt index 11f024cd77..d52615a501 100644 --- a/app/src/main/java/com/ivy/wallet/di/AppModule.kt +++ b/app/src/main/java/com/ivy/wallet/di/AppModule.kt @@ -19,6 +19,7 @@ import com.ivy.wallet.logic.loantrasactions.LTLoanRecordMapper import com.ivy.wallet.logic.loantrasactions.LoanTransactionsCore import com.ivy.wallet.logic.loantrasactions.LoanTransactionsLogic import com.ivy.wallet.logic.notification.TransactionReminderLogic +import com.ivy.wallet.logic.zip.ExportZipLogic import com.ivy.wallet.network.ErrorCodeTypeAdapter import com.ivy.wallet.network.FCMClient import com.ivy.wallet.network.LocalDateTimeTypeAdapter @@ -782,4 +783,29 @@ object AppModule { LoanRecord = LTLoanRecordMapper(ltCore = loanTransactionsCore) ) } + + @Provides + fun providesExportZipLogic( + accountDao: AccountDao, + budgetDao: BudgetDao, + categoryDao: CategoryDao, + loanRecordDao: LoanRecordDao, + loanDao: LoanDao, + plannedPaymentRuleDao: PlannedPaymentRuleDao, + settingsDao: SettingsDao, + transactionDao: TransactionDao, + sharedPrefs: SharedPrefs + ): ExportZipLogic { + return ExportZipLogic( + accountDao, + budgetDao, + categoryDao, + loanRecordDao, + loanDao, + plannedPaymentRuleDao, + settingsDao, + transactionDao, + sharedPrefs + ) + } } \ No newline at end of file diff --git a/app/src/main/java/com/ivy/wallet/logic/csv/model/ImportType.kt b/app/src/main/java/com/ivy/wallet/logic/csv/model/ImportType.kt index 8ddba26d87..baa6f375f2 100644 --- a/app/src/main/java/com/ivy/wallet/logic/csv/model/ImportType.kt +++ b/app/src/main/java/com/ivy/wallet/logic/csv/model/ImportType.kt @@ -60,7 +60,7 @@ enum class ImportType { } fun listName(): String = when (this) { - IVY -> "Ivy Wallet CSV" + IVY -> "Ivy Wallet" MONEY_MANAGER -> "Money Manager" WALLET_BY_BUDGET_BAKERS -> "Wallet by BudgetBakers" SPENDEE -> "Spendee" diff --git a/app/src/main/java/com/ivy/wallet/logic/zip/ExportZipLogic.kt b/app/src/main/java/com/ivy/wallet/logic/zip/ExportZipLogic.kt new file mode 100644 index 0000000000..dc90d3682a --- /dev/null +++ b/app/src/main/java/com/ivy/wallet/logic/zip/ExportZipLogic.kt @@ -0,0 +1,321 @@ +package com.ivy.wallet.logic.zip + +import android.content.Context +import android.net.Uri +import androidx.core.net.toUri +import com.google.gson.* +import com.google.gson.reflect.TypeToken +import com.ivy.wallet.base.ioThread +import com.ivy.wallet.base.readFile +import com.ivy.wallet.base.scopedIOThread +import com.ivy.wallet.base.toEpochMilli +import com.ivy.wallet.logic.csv.model.ImportResult +import com.ivy.wallet.logic.zip.model.IvyWalletCompleteData +import com.ivy.wallet.persistence.SharedPrefs +import com.ivy.wallet.persistence.dao.* +import kotlinx.coroutines.async +import java.io.File +import java.lang.reflect.Type +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.util.* + + +class ExportZipLogic( + private val accountDao: AccountDao, + private val budgetDao: BudgetDao, + private val categoryDao: CategoryDao, + private val loanRecordDao: LoanRecordDao, + private val loanDao: LoanDao, + private val plannedPaymentRuleDao: PlannedPaymentRuleDao, + private val settingsDao: SettingsDao, + private val transactionDao: TransactionDao, + private val sharedPrefs: SharedPrefs, +) { + suspend fun exportToFile( + context: Context, + zipFileUri: Uri + ) { + val jsonString = generateJsonString() + val file = createJsonDataFile(context, jsonString) + zip(context = context, zipFileUri, listOf(file)) + clearCacheDir(context) + } + + private fun createJsonDataFile(context: Context, jsonString: String): File { + val fileNamePrefix = "data" + val fileNameSuffix = ".json" + val outputDir = context.cacheDir + + val file = File.createTempFile(fileNamePrefix, fileNameSuffix, outputDir) + file.writeText(jsonString, Charsets.UTF_16) + + return file + } + + private suspend fun generateJsonString(): String { + return scopedIOThread { + val accounts = it.async { accountDao.findAll() } + val budgets = it.async { budgetDao.findAll() } + val categories = it.async { categoryDao.findAll() } + val loanRecords = it.async { loanRecordDao.findAll() } + val loans = it.async { loanDao.findAll() } + val plannedPaymentRules = it.async { plannedPaymentRuleDao.findAll() } + val settings = it.async { settingsDao.findAll() } + val transactions = it.async { transactionDao.findAll() } + val sharedPrefs = it.async { getSharedPrefsData() } + + val gson = GsonBuilder().registerTypeAdapter( + LocalDateTime::class.java, object : JsonSerializer { + @Throws(JsonParseException::class) + override fun serialize( + src: LocalDateTime?, + typeOfSrc: Type?, + context: JsonSerializationContext? + ): JsonElement { + return JsonPrimitive(src!!.toEpochMilli().toString()) + } + }).create() + + val completeData = IvyWalletCompleteData( + accounts = accounts.await(), + budgets = budgets.await(), + categories = categories.await(), + loanRecords = loanRecords.await(), + loans = loans.await(), + plannedPaymentRules = plannedPaymentRules.await(), + settings = settings.await(), + transactions = transactions.await(), + sharedPrefs = sharedPrefs.await() + ) + + gson.toJson(completeData) + } + } + + private fun getSharedPrefsData(): HashMap { + val hashmap = HashMap() + hashmap[SharedPrefs.SHOW_NOTIFICATIONS] = + sharedPrefs.getBoolean(SharedPrefs.SHOW_NOTIFICATIONS, true).toString() + + hashmap[SharedPrefs.APP_LOCK_ENABLED] = + sharedPrefs.getBoolean(SharedPrefs.APP_LOCK_ENABLED, false).toString() + + return hashmap + } + + suspend fun import( + context: Context, + zipFileUri: Uri, + onProgress: suspend (progressPercent: Double) -> Unit + ): ImportResult { + return ioThread { + return@ioThread try { + val folderName = "backup" + System.currentTimeMillis() + val cacheFolderPath = File(context.cacheDir, folderName) + + unzip(context, zipFileUri, cacheFolderPath) + + val filesArray = cacheFolderPath.listFiles() + + onProgress(0.05) + + if (filesArray == null || filesArray.isEmpty()) + ImportResult( + rowsFound = 0, + transactionsImported = 0, + accountsImported = 0, + categoriesImported = 0, + failedRows = emptyList() + ) + + val filesList = filesArray!!.toList().filter { + hasJsonExtension(it) + } + + onProgress(0.1) + + if (filesList.size != 1) + ImportResult( + rowsFound = 0, + transactionsImported = 0, + accountsImported = 0, + categoriesImported = 0, + failedRows = emptyList() + ) + + val jsonString = readFile(context, filesList[0].toUri(), Charsets.UTF_16) + val modifiedJsonString = accommodateExistingAccountsAndCategories(jsonString) + val ivyWalletCompleteData = getIvyWalletCompleteData(modifiedJsonString) + + onProgress(0.4) + insertDataToDb(completeData = ivyWalletCompleteData, onProgress = onProgress) + onProgress(1.0) + + clearCacheDir(context) + + ImportResult( + rowsFound = ivyWalletCompleteData.transactions.size, + transactionsImported = ivyWalletCompleteData.transactions.size, + accountsImported = ivyWalletCompleteData.accounts.size, + categoriesImported = ivyWalletCompleteData.categories.size, + failedRows = emptyList() + ) + + } catch (e: Exception) { + ImportResult( + rowsFound = 0, + transactionsImported = 0, + accountsImported = 0, + categoriesImported = 0, + failedRows = emptyList() + ) + } + } + } + + private suspend fun accommodateExistingAccountsAndCategories(jsonString: String?): String? { + val ivyWalletCompleteData = getIvyWalletCompleteData(jsonString) + val replacementPairs = getReplacementPairs(ivyWalletCompleteData) + + var modifiedString = jsonString + replacementPairs.forEach { + modifiedString = modifiedString!!.replace(it.first.toString(), it.second.toString()) + } + + return modifiedString + } + + private fun getIvyWalletCompleteData(data: String?): IvyWalletCompleteData { + val typeOfObjectsList: Type = + object : TypeToken() {}.type + + val gson: Gson = GsonBuilder().registerTypeAdapter( + LocalDateTime::class.java, object : JsonDeserializer { + @Throws(JsonParseException::class) + override fun deserialize( + json: JsonElement, + type: Type?, + jsonDeserializationContext: JsonDeserializationContext? + ): LocalDateTime? { + val instant: Instant = + Instant.ofEpochMilli(json.asJsonPrimitive.asLong) + return LocalDateTime.ofInstant(instant, ZoneOffset.UTC) + } + }).create() + + return gson.fromJson(data, typeOfObjectsList) + } + + private suspend fun insertDataToDb( + completeData: IvyWalletCompleteData, + onProgress: suspend (progressPercent: Double) -> Unit = {} + ) { + scopedIOThread { + transactionDao.save(completeData.transactions) + onProgress(0.6) + + val accounts = it.async { accountDao.save(completeData.accounts) } + val budgets = it.async { budgetDao.save(completeData.budgets) } + val categories = it.async { categoryDao.save(completeData.categories) } + accounts.await() + budgets.await() + categories.await() + + onProgress(0.7) + + val loans = it.async { loanDao.save(completeData.loans) } + val loanRecords = it.async { loanRecordDao.save(completeData.loanRecords) } + + loans.await() + loanRecords.await() + + onProgress(0.8) + + val plannedPayments = + it.async { plannedPaymentRuleDao.save(completeData.plannedPaymentRules) } + val settings = it.async { + settingsDao.deleteAll() + settingsDao.save(completeData.settings) + } + + sharedPrefs.putBoolean( + SharedPrefs.SHOW_NOTIFICATIONS, + (completeData.sharedPrefs[SharedPrefs.SHOW_NOTIFICATIONS] ?: "true").toBoolean() + ) + + sharedPrefs.putBoolean( + SharedPrefs.APP_LOCK_ENABLED, + (completeData.sharedPrefs[SharedPrefs.APP_LOCK_ENABLED] ?: "false").toBoolean() + ) + + plannedPayments.await() + settings.await() + + onProgress(0.9) + } + } + + /** This is used to replace account & category Ids in backup data with existing Ids + * This removes the problem of duplicate Accounts & Categories + * + * returns a Pair of IDs where A is the UUID that needs to be replaced with B + */ + private suspend fun getReplacementPairs( + completeData: IvyWalletCompleteData + ): List> { + return scopedIOThread { scope -> + val existingAccountsList = accountDao.findAll() + val existingCategoryList = categoryDao.findAll() + + val backupAccountsList = completeData.accounts + val backupCategoryList = completeData.categories + + if (existingAccountsList.isEmpty() && existingCategoryList.isEmpty()) + return@scopedIOThread emptyList() + + val sumAccountList = existingAccountsList + backupAccountsList + val sumCategoriesList = existingCategoryList + backupCategoryList + + val accountsReplace = scope.async { + sumAccountList.groupBy { it.name }.filter { it.value.size == 2 }.map { + val accountsZero = it.value[0] + val accountsFirst = it.value[1] + + if (backupAccountsList.contains(accountsZero)) + Pair(accountsZero.id, accountsFirst.id) + else + Pair(accountsFirst.id, accountsZero.id) + } + } + + val categoriesReplace = scope.async { + sumCategoriesList.groupBy { it.name }.filter { it.value.size == 2 }.map { + val categoryZero = it.value[0] + val categoryFirst = it.value[1] + + if (completeData.categories.contains(categoryZero)) + Pair(categoryZero.id, categoryFirst.id) + else + Pair(categoryFirst.id, categoryZero.id) + } + } + + return@scopedIOThread accountsReplace.await() + categoriesReplace.await() + } + } + + private fun hasJsonExtension(file: File): Boolean { + val name = file.name + val lastIndexOf = name.lastIndexOf(".") + if (lastIndexOf == -1) + return false + + return (name.substring(lastIndexOf).equals(".json", true)) + } + + private fun clearCacheDir(context: Context) { + context.cacheDir.deleteRecursively() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ivy/wallet/logic/zip/ZipUtils.kt b/app/src/main/java/com/ivy/wallet/logic/zip/ZipUtils.kt new file mode 100644 index 0000000000..b2afdcd7ec --- /dev/null +++ b/app/src/main/java/com/ivy/wallet/logic/zip/ZipUtils.kt @@ -0,0 +1,98 @@ +package com.ivy.wallet.logic.zip + +import android.content.Context +import android.net.Uri +import java.io.* +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream +import java.util.zip.ZipOutputStream + +private const val MODE_WRITE = "w" +private const val MODE_READ = "r" + +fun zip(zipFile: File, files: List) { + ZipOutputStream(BufferedOutputStream(FileOutputStream(zipFile))).use { outStream -> + zip(outStream, files) + } +} + +fun zip(context: Context, zipFile: Uri, files: List) { + context.contentResolver.openFileDescriptor(zipFile, MODE_WRITE).use { descriptor -> + descriptor?.fileDescriptor?.let { + ZipOutputStream(BufferedOutputStream(FileOutputStream(it))).use { outStream -> + zip(outStream, files) + } + } + } +} + +private fun zip( + outStream: ZipOutputStream, + files: List, + includeParentFolder: Boolean = false +) { + files.forEach { file -> + if (file.isDirectory) { + file.mkdir() + zip(outStream, file.listFiles()?.toList() ?: emptyList(), includeParentFolder = true) + } else { + val fileLoc: String = + if (file.parent.isNullOrEmpty() || !includeParentFolder) file.name else (file.parent!!).substring( + file.parent!!.lastIndexOf("/") + ) + "/" + file.name + + outStream.putNextEntry(ZipEntry(fileLoc)) + BufferedInputStream(FileInputStream(file)).use { inStream -> + inStream.copyTo(outStream) + } + } + + } +} + +fun unzip(zipFile: File, location: File) { + ZipInputStream(BufferedInputStream(FileInputStream(zipFile))).use { inStream -> + unzip(inStream, location) + } +} + +fun unzip(context: Context, zipFile: Uri, location: File) { + context.contentResolver.openFileDescriptor(zipFile, MODE_READ).use { descriptor -> + descriptor?.fileDescriptor?.let { + ZipInputStream(BufferedInputStream(FileInputStream(it))).use { inStream -> + unzip(inStream, location) + } + } + } +} + +private fun unzip(inStream: ZipInputStream, location: File) { + if (location.exists() && !location.isDirectory) + throw IllegalStateException("Location file must be directory or not exist") + + if (!location.isDirectory) location.mkdirs() + + val locationPath = location.absolutePath.let { + if (!it.endsWith(File.separator)) "$it${File.separator}" + else it + } + + var zipEntry: ZipEntry? + var unzipFile: File + var unzipParentDir: File? + + while (inStream.nextEntry.also { zipEntry = it } != null) { + unzipFile = File(locationPath + zipEntry!!.name) + if (zipEntry!!.isDirectory) { + if (!unzipFile.isDirectory) unzipFile.mkdirs() + } else { + unzipParentDir = unzipFile.parentFile + if (unzipParentDir != null && !unzipParentDir.isDirectory) { + unzipParentDir.mkdirs() + } + BufferedOutputStream(FileOutputStream(unzipFile)).use { outStream -> + inStream.copyTo(outStream) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ivy/wallet/logic/zip/model/IvyWalletCompleteData.kt b/app/src/main/java/com/ivy/wallet/logic/zip/model/IvyWalletCompleteData.kt new file mode 100644 index 0000000000..ef763d38b9 --- /dev/null +++ b/app/src/main/java/com/ivy/wallet/logic/zip/model/IvyWalletCompleteData.kt @@ -0,0 +1,15 @@ +package com.ivy.wallet.logic.zip.model + +import com.ivy.wallet.model.entity.* + +data class IvyWalletCompleteData( + val accounts: List = emptyList(), + val budgets: List = emptyList(), + val categories: List = emptyList(), + val loanRecords: List = emptyList(), + val loans: List = emptyList(), + val plannedPaymentRules: List = emptyList(), + val settings: List = emptyList(), + val transactions: List = emptyList(), + val sharedPrefs: HashMap = HashMap() +) \ No newline at end of file diff --git a/app/src/main/java/com/ivy/wallet/model/CustomExchangeRateState.kt b/app/src/main/java/com/ivy/wallet/model/CustomExchangeRateState.kt new file mode 100644 index 0000000000..fa8636adbf --- /dev/null +++ b/app/src/main/java/com/ivy/wallet/model/CustomExchangeRateState.kt @@ -0,0 +1,9 @@ +package com.ivy.wallet.model + +data class CustomExchangeRateState( + val showCard: Boolean = false, + val toCurrencyCode: String? = null, + val fromCurrencyCode: String? = null, + val exchangeRate: Double = 1.0, + val convertedAmount: Double? = null +) \ No newline at end of file diff --git a/app/src/main/java/com/ivy/wallet/persistence/dao/AccountDao.kt b/app/src/main/java/com/ivy/wallet/persistence/dao/AccountDao.kt index 9970b47138..b7e9e1a734 100644 --- a/app/src/main/java/com/ivy/wallet/persistence/dao/AccountDao.kt +++ b/app/src/main/java/com/ivy/wallet/persistence/dao/AccountDao.kt @@ -12,6 +12,9 @@ interface AccountDao { @Insert(onConflict = OnConflictStrategy.REPLACE) fun save(value: Account) + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun save(value: List) + @Query("SELECT * FROM accounts WHERE isDeleted = 0 ORDER BY orderNum ASC") fun findAll(): List diff --git a/app/src/main/java/com/ivy/wallet/persistence/dao/BudgetDao.kt b/app/src/main/java/com/ivy/wallet/persistence/dao/BudgetDao.kt index d21088515d..db754564dc 100644 --- a/app/src/main/java/com/ivy/wallet/persistence/dao/BudgetDao.kt +++ b/app/src/main/java/com/ivy/wallet/persistence/dao/BudgetDao.kt @@ -12,6 +12,9 @@ interface BudgetDao { @Insert(onConflict = OnConflictStrategy.REPLACE) fun save(value: Budget) + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun save(value: List) + @Query("SELECT * FROM budgets WHERE isDeleted = 0 ORDER BY orderId ASC") fun findAll(): List diff --git a/app/src/main/java/com/ivy/wallet/persistence/dao/CategoryDao.kt b/app/src/main/java/com/ivy/wallet/persistence/dao/CategoryDao.kt index c7ec9a7569..55a166cdbb 100644 --- a/app/src/main/java/com/ivy/wallet/persistence/dao/CategoryDao.kt +++ b/app/src/main/java/com/ivy/wallet/persistence/dao/CategoryDao.kt @@ -12,6 +12,9 @@ interface CategoryDao { @Insert(onConflict = OnConflictStrategy.REPLACE) fun save(value: Category) + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun save(value: List) + @Query("SELECT * FROM categories WHERE isDeleted = 0 ORDER BY orderNum ASC") fun findAll(): List diff --git a/app/src/main/java/com/ivy/wallet/persistence/dao/LoanDao.kt b/app/src/main/java/com/ivy/wallet/persistence/dao/LoanDao.kt index 07876cac1f..c92476f0ff 100644 --- a/app/src/main/java/com/ivy/wallet/persistence/dao/LoanDao.kt +++ b/app/src/main/java/com/ivy/wallet/persistence/dao/LoanDao.kt @@ -12,6 +12,9 @@ interface LoanDao { @Insert(onConflict = OnConflictStrategy.REPLACE) fun save(value: Loan) + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun save(value: List) + @Query("SELECT * FROM loans WHERE isDeleted = 0 ORDER BY orderNum ASC") fun findAll(): List diff --git a/app/src/main/java/com/ivy/wallet/persistence/dao/PlannedPaymentRuleDao.kt b/app/src/main/java/com/ivy/wallet/persistence/dao/PlannedPaymentRuleDao.kt index f81347add5..3630b14dd7 100644 --- a/app/src/main/java/com/ivy/wallet/persistence/dao/PlannedPaymentRuleDao.kt +++ b/app/src/main/java/com/ivy/wallet/persistence/dao/PlannedPaymentRuleDao.kt @@ -12,6 +12,9 @@ interface PlannedPaymentRuleDao { @Insert(onConflict = OnConflictStrategy.REPLACE) fun save(value: PlannedPaymentRule) + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun save(value: List) + @Query("SELECT * FROM planned_payment_rules WHERE isDeleted = 0 ORDER BY amount DESC, startDate ASC") fun findAll(): List diff --git a/app/src/main/java/com/ivy/wallet/persistence/dao/SettingsDao.kt b/app/src/main/java/com/ivy/wallet/persistence/dao/SettingsDao.kt index d94b889a4d..095260d834 100644 --- a/app/src/main/java/com/ivy/wallet/persistence/dao/SettingsDao.kt +++ b/app/src/main/java/com/ivy/wallet/persistence/dao/SettingsDao.kt @@ -12,6 +12,9 @@ interface SettingsDao { @Insert(onConflict = OnConflictStrategy.REPLACE) fun save(value: Settings) + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun save(value: List) + @Query("SELECT * FROM settings LIMIT 1") fun findFirst(): Settings diff --git a/app/src/main/java/com/ivy/wallet/persistence/dao/TransactionDao.kt b/app/src/main/java/com/ivy/wallet/persistence/dao/TransactionDao.kt index 4d49168999..47ac1c372a 100644 --- a/app/src/main/java/com/ivy/wallet/persistence/dao/TransactionDao.kt +++ b/app/src/main/java/com/ivy/wallet/persistence/dao/TransactionDao.kt @@ -14,6 +14,9 @@ interface TransactionDao { @Insert(onConflict = OnConflictStrategy.REPLACE) fun save(value: Transaction) + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun save(value: List) + @Query("SELECT * FROM transactions WHERE isDeleted = 0 ORDER BY dateTime DESC, dueDate ASC") fun findAll(): List diff --git a/app/src/main/java/com/ivy/wallet/ui/IvyActivity.kt b/app/src/main/java/com/ivy/wallet/ui/IvyActivity.kt index 3d0d4dc3b0..4b63691074 100644 --- a/app/src/main/java/com/ivy/wallet/ui/IvyActivity.kt +++ b/app/src/main/java/com/ivy/wallet/ui/IvyActivity.kt @@ -491,6 +491,17 @@ class IvyActivity : AppCompatActivity() { startActivity(intent) } + fun shareZipFile(fileUri: Uri) { + val intent = Intent.createChooser( + Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_STREAM, fileUri) + type = "application/zip" + }, null + ) + startActivity(intent) + } + fun reviewIvyWallet(dismissReviewCard: Boolean) { val manager = ReviewManagerFactory.create(this) val request = manager.requestReviewFlow() diff --git a/app/src/main/java/com/ivy/wallet/ui/csvimport/ImportScreen.kt b/app/src/main/java/com/ivy/wallet/ui/csvimport/ImportScreen.kt index 8c56582c56..f01e6a6c70 100644 --- a/app/src/main/java/com/ivy/wallet/ui/csvimport/ImportScreen.kt +++ b/app/src/main/java/com/ivy/wallet/ui/csvimport/ImportScreen.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.BoxWithConstraintsScope import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.lifecycle.viewmodel.compose.viewModel import com.ivy.wallet.base.onScreenStart @@ -34,6 +35,7 @@ fun BoxWithConstraintsScope.ImportCSVScreen(screen: Import) { onScreenStart { viewModel.start(screen) } + val context = LocalContext.current UI( screen = screen, @@ -43,7 +45,7 @@ fun BoxWithConstraintsScope.ImportCSVScreen(screen: Import) { importResult = importResult, onChooseImportType = viewModel::setImportType, - onUploadCSVFile = viewModel::uploadFile, + onUploadCSVFile = { viewModel.uploadFile(context) }, onSkip = { viewModel.skip( screen = screen, diff --git a/app/src/main/java/com/ivy/wallet/ui/csvimport/ImportViewModel.kt b/app/src/main/java/com/ivy/wallet/ui/csvimport/ImportViewModel.kt index 3b75de9537..fcaf14d355 100644 --- a/app/src/main/java/com/ivy/wallet/ui/csvimport/ImportViewModel.kt +++ b/app/src/main/java/com/ivy/wallet/ui/csvimport/ImportViewModel.kt @@ -1,5 +1,7 @@ package com.ivy.wallet.ui.csvimport +import android.content.Context +import android.net.Uri import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -14,6 +16,7 @@ import com.ivy.wallet.logic.csv.CSVNormalizer import com.ivy.wallet.logic.csv.IvyFileReader import com.ivy.wallet.logic.csv.model.ImportResult import com.ivy.wallet.logic.csv.model.ImportType +import com.ivy.wallet.logic.zip.ExportZipLogic import com.ivy.wallet.ui.Import import com.ivy.wallet.ui.IvyWalletCtx import com.ivy.wallet.ui.onboarding.viewmodel.OnboardingViewModel @@ -30,7 +33,8 @@ class ImportViewModel @Inject constructor( private val fileReader: IvyFileReader, private val csvNormalizer: CSVNormalizer, private val csvMapper: CSVMapper, - private val csvImporter: CSVImporter + private val csvImporter: CSVImporter, + private val exportZipLogic: ExportZipLogic ) : ViewModel() { private val _importStep = MutableLiveData() val importStep = _importStep.asLiveData() @@ -66,7 +70,7 @@ class ImportViewModel @Inject constructor( } @ExperimentalStdlibApi - fun uploadFile() { + fun uploadFile(context: Context) { val importType = importType.value ?: return ivyContext.openFile { fileUri -> @@ -75,66 +79,83 @@ class ImportViewModel @Inject constructor( _importStep.value = ImportStep.LOADING - _importResult.value = ioThread { - val rawCSV = fileReader.read( - uri = fileUri, - charset = when (importType) { - ImportType.IVY -> Charsets.UTF_16 - else -> Charsets.UTF_8 - } - ) - if (rawCSV == null || rawCSV.isBlank()) { - return@ioThread ImportResult( - rowsFound = 0, - transactionsImported = 0, - accountsImported = 0, - categoriesImported = 0, - failedRows = emptyList() - ) - } - - val normalizedCSV = csvNormalizer.normalize( - rawCSV = rawCSV, - importType = importType - ) - - val mapping = csvMapper.mapping( - type = importType, - headerRow = normalizedCSV.split("\n").getOrNull(0) - ) - - return@ioThread try { - val result = csvImporter.import( - csv = normalizedCSV, - rowMapping = mapping, - onProgress = { progressPercent -> - uiThread { - _importProgressPercent.value = - (progressPercent * 100).roundToInt() - } + _importResult.value = if (hasCSVExtension(fileUri)) + restoreCSVFile(fileUri = fileUri, importType = importType) + else { + exportZipLogic.import( + context = context, + zipFileUri = fileUri, + onProgress = { progressPercent -> + uiThread { + _importProgressPercent.value = + (progressPercent * 100).roundToInt() } - ) + }) + } - if (result.failedRows.isNotEmpty()) { - Timber.e("Import failed rows: ${result.failedRows}") - } + _importStep.value = ImportStep.RESULT + + TestIdlingResource.decrement() + } + } + } - result - } catch (e: Exception) { - e.printStackTrace() - ImportResult( - rowsFound = 0, - transactionsImported = 0, - accountsImported = 0, - categoriesImported = 0, - failedRows = emptyList() - ) + @ExperimentalStdlibApi + private suspend fun restoreCSVFile(fileUri: Uri, importType: ImportType): ImportResult { + return ioThread { + val rawCSV = fileReader.read( + uri = fileUri, + charset = when (importType) { + ImportType.IVY -> Charsets.UTF_16 + else -> Charsets.UTF_8 + } + ) + if (rawCSV == null || rawCSV.isBlank()) { + return@ioThread ImportResult( + rowsFound = 0, + transactionsImported = 0, + accountsImported = 0, + categoriesImported = 0, + failedRows = emptyList() + ) + } + + val normalizedCSV = csvNormalizer.normalize( + rawCSV = rawCSV, + importType = importType + ) + + val mapping = csvMapper.mapping( + type = importType, + headerRow = normalizedCSV.split("\n").getOrNull(0) + ) + + return@ioThread try { + val result = csvImporter.import( + csv = normalizedCSV, + rowMapping = mapping, + onProgress = { progressPercent -> + uiThread { + _importProgressPercent.value = + (progressPercent * 100).roundToInt() + } } - }!! + ) - _importStep.value = ImportStep.RESULT + if (result.failedRows.isNotEmpty()) { + Timber.e("Import failed rows: ${result.failedRows}") + } - TestIdlingResource.decrement() + result + } catch (e: Exception) { + e.printStackTrace() + ImportResult( + rowsFound = 0, + transactionsImported = 0, + accountsImported = 0, + categoriesImported = 0, + failedRows = emptyList() + ) } } } @@ -174,4 +195,11 @@ class ImportViewModel @Inject constructor( private fun resetState() { _importStep.value = ImportStep.IMPORT_FROM } + + private fun hasCSVExtension(fileUri: Uri): Boolean { + var ex = fileUri.toString() + ex = ex.substring(ex.lastIndexOf(".")) + + return ex.equals(".csv", ignoreCase = true) + } } \ No newline at end of file diff --git a/app/src/main/java/com/ivy/wallet/ui/csvimport/flow/instructions/ImportInstructions.kt b/app/src/main/java/com/ivy/wallet/ui/csvimport/flow/instructions/ImportInstructions.kt index 724718925c..9bcbb53dfc 100644 --- a/app/src/main/java/com/ivy/wallet/ui/csvimport/flow/instructions/ImportInstructions.kt +++ b/app/src/main/java/com/ivy/wallet/ui/csvimport/flow/instructions/ImportInstructions.kt @@ -247,11 +247,12 @@ fun InstructionButton( @Composable fun UploadFileStep( stepNumber: Int, + text: String = "Upload CSV file", onUploadClick: () -> Unit ) { StepTitle( number = stepNumber, - title = "Upload CSV file" + title = text ) Spacer(Modifier.height(16.dp)) @@ -260,7 +261,7 @@ fun UploadFileStep( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), - text = "Upload CSV file", + text = text, textColor = White, backgroundGradient = GradientIvy, hasNext = false, diff --git a/app/src/main/java/com/ivy/wallet/ui/csvimport/flow/instructions/IvyWalletSteps.kt b/app/src/main/java/com/ivy/wallet/ui/csvimport/flow/instructions/IvyWalletSteps.kt index 8f4d9eab26..1539ed6151 100644 --- a/app/src/main/java/com/ivy/wallet/ui/csvimport/flow/instructions/IvyWalletSteps.kt +++ b/app/src/main/java/com/ivy/wallet/ui/csvimport/flow/instructions/IvyWalletSteps.kt @@ -1,12 +1,34 @@ package com.ivy.wallet.ui.csvimport.flow.instructions +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp @Composable fun IvyWalletSteps( onUploadClick: () -> Unit ) { - DefaultImportSteps( + Spacer(Modifier.height(12.dp)) + + StepTitle( + number = 1, + title = "Export Data" + ) + + Spacer(Modifier.height(12.dp)) + + VideoArticleRow( + videoUrl = null, + articleUrl = null + ) + + Spacer(Modifier.height(24.dp)) + + UploadFileStep( + stepNumber = 2, + text = "Upload CSV/ZIP file", onUploadClick = onUploadClick ) } \ No newline at end of file diff --git a/app/src/main/java/com/ivy/wallet/ui/edit/EditTransactionScreen.kt b/app/src/main/java/com/ivy/wallet/ui/edit/EditTransactionScreen.kt index 2c1d20de1d..314fac138b 100644 --- a/app/src/main/java/com/ivy/wallet/ui/edit/EditTransactionScreen.kt +++ b/app/src/main/java/com/ivy/wallet/ui/edit/EditTransactionScreen.kt @@ -9,6 +9,8 @@ import androidx.compose.runtime.* import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInParent import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.tooling.preview.Preview @@ -17,10 +19,16 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.google.accompanist.insets.navigationBarsPadding import com.google.accompanist.insets.statusBarsPadding import com.ivy.design.api.navigation +import com.ivy.design.l0_system.UI +import com.ivy.design.l0_system.style import com.ivy.wallet.R -import com.ivy.wallet.base.* +import com.ivy.wallet.base.convertUTCtoLocal +import com.ivy.wallet.base.getTrueDate +import com.ivy.wallet.base.onScreenStart +import com.ivy.wallet.base.timeNowLocal import com.ivy.wallet.logic.model.CreateAccountData import com.ivy.wallet.logic.model.CreateCategoryData +import com.ivy.wallet.model.CustomExchangeRateState import com.ivy.wallet.model.TransactionType import com.ivy.wallet.model.entity.Account import com.ivy.wallet.model.entity.Category @@ -32,11 +40,12 @@ import com.ivy.wallet.ui.ivyWalletCtx import com.ivy.wallet.ui.loan.data.EditTransactionDisplayLoan import com.ivy.wallet.ui.theme.components.AddPrimaryAttributeButton import com.ivy.wallet.ui.theme.components.ChangeTransactionTypeModal +import com.ivy.wallet.ui.theme.components.CustomExchangeRateCard import com.ivy.wallet.ui.theme.modal.* import com.ivy.wallet.ui.theme.modal.edit.* import java.time.LocalDateTime -import com.ivy.design.l0_system.UI -import com.ivy.design.l0_system.style +import java.util.* +import kotlin.math.roundToInt @ExperimentalFoundationApi @Composable @@ -56,6 +65,7 @@ fun BoxWithConstraintsScope.EditTransactionScreen(screen: EditTransaction) { val amount by viewModel.amount.observeAsState(0.0) val loanData by viewModel.displayLoanHelper.collectAsState() val backgroundProcessing by viewModel.backgroundProcessingStarted.collectAsState() + val customExchangeRateState by viewModel.customExchangeRateState.collectAsState() val categories by viewModel.categories.observeAsState(emptyList()) val accounts by viewModel.accounts.observeAsState(emptyList()) @@ -81,6 +91,7 @@ fun BoxWithConstraintsScope.EditTransactionScreen(screen: EditTransaction) { amount = amount, loanData = loanData, backgroundProcessing = backgroundProcessing, + customExchangeRateState = customExchangeRateState, categories = categories, accounts = accounts, @@ -103,7 +114,10 @@ fun BoxWithConstraintsScope.EditTransactionScreen(screen: EditTransaction) { onSave = viewModel::save, onSetHasChanges = viewModel::setHasChanges, onDelete = viewModel::delete, - onCreateAccount = viewModel::createAccount + onCreateAccount = viewModel::createAccount, + onExchangeRateChanged = { + viewModel.updateExchangeRate(exRate = it) + } ) } @@ -124,6 +138,7 @@ private fun BoxWithConstraintsScope.UI( amount: Double, loanData: EditTransactionDisplayLoan = EditTransactionDisplayLoan(), backgroundProcessing: Boolean = false, + customExchangeRateState: CustomExchangeRateState, categories: List, accounts: List, @@ -147,6 +162,7 @@ private fun BoxWithConstraintsScope.UI( onSetHasChanges: (hasChanges: Boolean) -> Unit, onDelete: () -> Unit, onCreateAccount: (CreateAccountData) -> Unit, + onExchangeRateChanged: (Double) -> Unit = { } ) { var chooseCategoryModalVisible by remember { mutableStateOf(false) } var categoryModalData: CategoryModalData? by remember { mutableStateOf(null) } @@ -155,6 +171,7 @@ private fun BoxWithConstraintsScope.UI( var deleteTrnModalVisible by remember { mutableStateOf(false) } var changeTransactionTypeModalVisible by remember { mutableStateOf(false) } var amountModalShown by remember { mutableStateOf(false) } + var exchangeRateAmountModalShown by remember { mutableStateOf(false) } var accountChangeModal by remember { mutableStateOf(false) } val waitModalVisible by remember(backgroundProcessing) { mutableStateOf(backgroundProcessing) @@ -173,6 +190,14 @@ private fun BoxWithConstraintsScope.UI( val titleFocus = FocusRequester() val scrollState = rememberScrollState() + //This is to scroll the column to the customExchangeCard composable when it is shown + var customExchangeRatePosition by remember { mutableStateOf(0F) } + LaunchedEffect(key1 = customExchangeRateState.showCard) { + val scrollInt = + if (customExchangeRateState.showCard) customExchangeRatePosition.roundToInt() else 0 + scrollState.animateScrollTo(scrollInt) + } + Column( modifier = Modifier .fillMaxSize() @@ -281,6 +306,20 @@ private fun BoxWithConstraintsScope.UI( } } + if (transactionType == TransactionType.TRANSFER && customExchangeRateState.showCard) { + Spacer(Modifier.height(12.dp)) + CustomExchangeRateCard( + fromCurrencyCode = baseCurrency, + toCurrencyCode = customExchangeRateState.toCurrencyCode ?: baseCurrency, + exchangeRate = customExchangeRateState.exchangeRate, + modifier = Modifier.onGloballyPositioned { coordinates -> + customExchangeRatePosition = coordinates.positionInParent().y * 0.3f + } + ) { + exchangeRateAmountModalShown = true + } + } + if (dueDate == null && transactionType != TransactionType.TRANSFER && dateTime == null) { Spacer(Modifier.height(12.dp)) @@ -322,6 +361,8 @@ private fun BoxWithConstraintsScope.UI( toAccount = toAccount, amount = amount, currency = baseCurrency, + convertedAmount = customExchangeRateState.convertedAmount, + convertedAmountCurrencyCode = customExchangeRateState.toCurrencyCode, ActionButton = { if (screen.initialTransactionId != null) { @@ -468,11 +509,24 @@ private fun BoxWithConstraintsScope.UI( accountChangeModal = false } - ConfirmationModal( + ProgressModal( title = "Confirm Account Change", description = "Please wait, re-calculating all loan records", visible = waitModalVisible ) + + AmountModal( + id = UUID.randomUUID(), + visible = exchangeRateAmountModalShown, + currency = "", + initialAmount = customExchangeRateState.exchangeRate, + dismiss = { exchangeRateAmountModalShown = false }, + decimalCountMax = 4, + onAmountChanged = { + onExchangeRateChanged(it) + } + ) + } private fun shouldFocusCategory( @@ -505,6 +559,7 @@ private fun Preview() { amount = 0.0, dueDate = null, transactionType = TransactionType.INCOME, + customExchangeRateState = CustomExchangeRateState(), categories = emptyList(), accounts = emptyList(), diff --git a/app/src/main/java/com/ivy/wallet/ui/edit/EditTransactionViewModel.kt b/app/src/main/java/com/ivy/wallet/ui/edit/EditTransactionViewModel.kt index c25eb710c4..51f0bc7241 100644 --- a/app/src/main/java/com/ivy/wallet/ui/edit/EditTransactionViewModel.kt +++ b/app/src/main/java/com/ivy/wallet/ui/edit/EditTransactionViewModel.kt @@ -4,16 +4,14 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.ivy.design.navigation.Navigation -import com.ivy.wallet.base.TestIdlingResource -import com.ivy.wallet.base.asLiveData -import com.ivy.wallet.base.ioThread -import com.ivy.wallet.base.timeNowUTC +import com.ivy.wallet.base.* import com.ivy.wallet.event.AccountsUpdatedEvent import com.ivy.wallet.logic.* import com.ivy.wallet.logic.currency.ExchangeRatesLogic import com.ivy.wallet.logic.loantrasactions.LoanTransactionsLogic import com.ivy.wallet.logic.model.CreateAccountData import com.ivy.wallet.logic.model.CreateCategoryData +import com.ivy.wallet.model.CustomExchangeRateState import com.ivy.wallet.model.TransactionType import com.ivy.wallet.model.entity.Account import com.ivy.wallet.model.entity.Category @@ -105,6 +103,9 @@ class EditTransactionViewModel @Inject constructor( private val _backgroundProcessingStarted = MutableStateFlow(false) val backgroundProcessingStarted = _backgroundProcessingStarted.asStateFlow() + private val _customExchangeRateState = MutableStateFlow(CustomExchangeRateState()) + val customExchangeRateState = _customExchangeRateState.asStateFlow() + private var loadedTransaction: Transaction? = null private var editMode = false @@ -112,6 +113,7 @@ class EditTransactionViewModel @Inject constructor( private var accountsChanged = false var title: String? = null + private lateinit var baseUserCurrency: String fun start(screen: EditTransaction) { viewModelScope.launch { @@ -119,6 +121,8 @@ class EditTransactionViewModel @Inject constructor( editMode = screen.initialTransactionId != null + baseUserCurrency = baseCurrency() + val accounts = ioThread { accountDao.findAll() }!! if (accounts.isEmpty()) { closeScreen() @@ -216,6 +220,22 @@ class EditTransactionViewModel @Inject constructor( updateCurrency(account = selectedAccount) + transaction.toAmount?.let { + val exchangeRate = it / transaction.amount + val toAccountCurrency = + _accounts.value?.find { acc -> acc.id == transaction.toAccountId }?.currency + _customExchangeRateState.value = + _customExchangeRateState.value.copy( + showCard = toAccountCurrency != account.value?.currency, + exchangeRate = exchangeRate, + convertedAmount = it, + toCurrencyCode = toAccountCurrency, + fromCurrencyCode = currency.value + ) + } ?: let { + _customExchangeRateState.value = CustomExchangeRateState() + } + _displayLoanHelper.value = getDisplayLoanHelper(trans = transaction) } @@ -226,12 +246,15 @@ class EditTransactionViewModel @Inject constructor( private suspend fun baseCurrency(): String = ioThread { settingsDao.findFirst().currency } fun onAmountChanged(newAmount: Double) { - loadedTransaction = loadedTransaction().copy( - amount = newAmount - ) - _amount.value = newAmount + viewModelScope.launch { + loadedTransaction = loadedTransaction().copy( + amount = newAmount + ) + _amount.value = newAmount + updateCustomExchangeRateState(amt = newAmount) - saveIfEditMode() + saveIfEditMode() + } } fun onTitleChanged(newTitle: String?) { @@ -282,36 +305,43 @@ class EditTransactionViewModel @Inject constructor( } fun onAccountChanged(newAccount: Account) { - TestIdlingResource.increment() + viewModelScope.launch { + TestIdlingResource.increment() - loadedTransaction = loadedTransaction().copy( - accountId = newAccount.id - ) - _account.value = newAccount + loadedTransaction = loadedTransaction().copy( + accountId = newAccount.id + ) + _account.value = newAccount - viewModelScope.launch { - updateCurrency(account = newAccount) - } + updateCustomExchangeRateState(fromAccount = newAccount) + + viewModelScope.launch { + updateCurrency(account = newAccount) + } - accountsChanged = true + accountsChanged = true - //update last selected account - sharedPrefs.putString(SharedPrefs.LAST_SELECTED_ACCOUNT_ID, newAccount.id.toString()) + //update last selected account + sharedPrefs.putString(SharedPrefs.LAST_SELECTED_ACCOUNT_ID, newAccount.id.toString()) - saveIfEditMode() + saveIfEditMode() - updateTitleSuggestions() + updateTitleSuggestions() - TestIdlingResource.decrement() + TestIdlingResource.decrement() + } } fun onToAccountChanged(newAccount: Account) { - loadedTransaction = loadedTransaction().copy( - toAccountId = newAccount.id - ) - _toAccount.value = newAccount + viewModelScope.launch { + loadedTransaction = loadedTransaction().copy( + toAccountId = newAccount.id + ) + _toAccount.value = newAccount + updateCustomExchangeRateState(toAccount = newAccount) - saveIfEditMode() + saveIfEditMode() + } } fun onDueDateChanged(newDueDate: LocalDateTime?) { @@ -459,7 +489,7 @@ class EditTransactionViewModel @Inject constructor( loadedTransaction = loadedTransaction().copy( accountId = account.value?.id ?: error("no accountId"), toAccountId = toAccount.value?.id, - toAmount = transferToAmount(amount = amount), + toAmount = _customExchangeRateState.value.convertedAmount, title = title?.trim(), description = description.value?.trim(), amount = amount, @@ -558,4 +588,60 @@ class EditTransactionViewModel @Inject constructor( } private fun loadedTransaction() = loadedTransaction ?: error("Loaded transaction is null") + + private suspend fun updateCustomExchangeRateState( + toAccount: Account? = null, + fromAccount: Account? = null, + amt: Double? = null, + exchangeRate: Double? = null + ) { + computationThread { + + val toAcc = toAccount ?: _toAccount.value + val fromAcc = fromAccount ?: _account.value + + val toAccCurrencyCode = toAcc?.currency ?: baseUserCurrency + val fromAccCurrencyCode = fromAcc?.currency ?: baseUserCurrency + + if (toAcc == null || fromAcc == null || (toAccCurrencyCode == fromAccCurrencyCode)) { + _customExchangeRateState.value = CustomExchangeRateState() + return@computationThread + } + + val exRate = exchangeRate + ?: if (customExchangeRateState.value.showCard && toAccCurrencyCode == customExchangeRateState.value.toCurrencyCode + && fromAccCurrencyCode == customExchangeRateState.value.fromCurrencyCode + ) + customExchangeRateState.value.exchangeRate + else + exchangeRatesLogic.convertAmount( + baseCurrency = baseUserCurrency, + amount = 1.0, + fromCurrency = fromAccCurrencyCode, + toCurrency = toAccCurrencyCode + ) + + + val amount = amt ?: _amount.value ?: 0.0 + + val customTransferExchangeRateState = CustomExchangeRateState( + showCard = true, + toCurrencyCode = toAccCurrencyCode, + fromCurrencyCode = fromAccCurrencyCode, + exchangeRate = exRate, + convertedAmount = exRate * amount + ) + + _customExchangeRateState.value = customTransferExchangeRateState + uiThread { + saveIfEditMode() + } + } + } + + fun updateExchangeRate(exRate: Double) { + viewModelScope.launch { + updateCustomExchangeRateState(exchangeRate = exRate) + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/ivy/wallet/ui/edit/core/EditBottomSheet.kt b/app/src/main/java/com/ivy/wallet/ui/edit/core/EditBottomSheet.kt index 0408de782a..1dc0817988 100644 --- a/app/src/main/java/com/ivy/wallet/ui/edit/core/EditBottomSheet.kt +++ b/app/src/main/java/com/ivy/wallet/ui/edit/core/EditBottomSheet.kt @@ -28,14 +28,15 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.google.accompanist.insets.statusBarsPadding -import com.ivy.design.api.ivyContext import com.ivy.design.l0_system.UI import com.ivy.design.l0_system.style +import com.ivy.wallet.Constants import com.ivy.wallet.R import com.ivy.wallet.base.* import com.ivy.wallet.model.TransactionType import com.ivy.wallet.model.entity.Account import com.ivy.wallet.ui.IvyWalletPreview +import com.ivy.wallet.ui.ivyWalletCtx import com.ivy.wallet.ui.theme.* import com.ivy.wallet.ui.theme.components.* import com.ivy.wallet.ui.theme.modal.DURATION_MODAL_KEYBOARD @@ -55,6 +56,8 @@ fun BoxWithConstraintsScope.EditBottomSheet( toAccount: Account?, amount: Double, currency: String, + convertedAmount: Double? = null, + convertedAmountCurrencyCode: String? = null, amountModalShown: Boolean, setAmountModalShown: (Boolean) -> Unit, @@ -65,7 +68,6 @@ fun BoxWithConstraintsScope.EditBottomSheet( onToAccountChanged: (Account) -> Unit, onAddNewAccount: () -> Unit ) { - val ivyContext = ivyContext() val rootView = LocalView.current var keyboardShown by remember { mutableStateOf(false) } @@ -99,6 +101,13 @@ fun BoxWithConstraintsScope.EditBottomSheet( ) val percentCollapsed = 1f - percentExpanded + val showConvertedAmountText by remember(convertedAmount) { + if (type == TransactionType.TRANSFER && convertedAmount != null && convertedAmountCurrencyCode != null) + mutableStateOf("${convertedAmount.format(2)} $convertedAmountCurrencyCode") + else + mutableStateOf(null) + } + Column( modifier = Modifier .align(Alignment.BottomCenter) @@ -112,6 +121,16 @@ fun BoxWithConstraintsScope.EditBottomSheet( shadowRadius = 24.dp ) .background(UI.colors.pure, UI.shapes.r2Top) + .verticalSwipeListener( + sensitivity = Constants.SWIPE_UP_EXPANDED_THRESHOLD, + onSwipeUp = { + hideKeyboard(rootView) + internalExpanded = true + }, + onSwipeDown = { + internalExpanded = false + } + ) .consumeClicks() ) { //Accounts label @@ -153,6 +172,7 @@ fun BoxWithConstraintsScope.EditBottomSheet( currency = currency, label = label, account = selectedAccount, + showConvertedAmountText = showConvertedAmountText, percentExpanded = percentExpanded, onShowAmountModal = { setAmountModalShown(true) @@ -160,7 +180,7 @@ fun BoxWithConstraintsScope.EditBottomSheet( onAccountMiniClick = { hideKeyboard(rootView) internalExpanded = true - } + }, ) val lastSpacer = lerp(20f, 8f, percentCollapsed) @@ -240,7 +260,7 @@ private fun BottomBar( navBarPadding: Dp, ActionButton: @Composable () -> Unit ) { - val ivyContext = ivyContext() + val ivyContext = ivyWalletCtx() ActionsRow( modifier = Modifier @@ -589,6 +609,7 @@ private fun Amount( percentExpanded: Float, label: String, account: Account?, + showConvertedAmountText: String? = null, onShowAmountModal: () -> Unit, onAccountMiniClick: () -> Unit, ) { @@ -612,26 +633,37 @@ private fun Amount( ) } - BalanceRow( - modifier = Modifier - .clickableNoIndication { - onShowAmountModal() - } - .testTag("edit_amount_balance_row"), - currency = currency, - balance = amount, + Column() { + BalanceRow( + modifier = Modifier + .clickableNoIndication { + onShowAmountModal() + } + .testTag("edit_amount_balance_row"), + currency = currency, + balance = amount, - decimalPaddingTop = currencyPaddingTop.dp, - spacerDecimal = spacerInteger.dp, - spacerCurrency = 8.dp, + decimalPaddingTop = currencyPaddingTop.dp, + spacerDecimal = spacerInteger.dp, + spacerCurrency = 8.dp, - integerFontSize = integerFontSize.sp, - decimalFontSize = 18.sp, - currencyFontSize = currencyFontSize.sp, + integerFontSize = integerFontSize.sp, + decimalFontSize = 18.sp, + currencyFontSize = currencyFontSize.sp, - currencyUpfront = false - ) + currencyUpfront = false + ) + if (showConvertedAmountText != null) { + Text( + text = showConvertedAmountText, + style = UI.typo.nB2.style( + color = UI.colors.pureInverse, + fontWeight = FontWeight.SemiBold + ) + ) + } + } Spacer(Modifier.weight(1f)) diff --git a/app/src/main/java/com/ivy/wallet/ui/home/HomeMoreMenu.kt b/app/src/main/java/com/ivy/wallet/ui/home/HomeMoreMenu.kt index 8b60b7683d..a8ad89b5f8 100644 --- a/app/src/main/java/com/ivy/wallet/ui/home/HomeMoreMenu.kt +++ b/app/src/main/java/com/ivy/wallet/ui/home/HomeMoreMenu.kt @@ -404,18 +404,22 @@ private fun QuickAccess( icon = when (theme) { Theme.LIGHT -> R.drawable.home_more_menu_light_mode Theme.DARK -> R.drawable.home_more_menu_dark_mode + Theme.AUTO -> R.drawable.home_more_menu_auto_mode }, label = when (theme) { Theme.LIGHT -> "Light mode" Theme.DARK -> "Dark mode" + Theme.AUTO -> "Auto mode" }, backgroundColor = when (theme) { Theme.LIGHT -> UI.colors.pure Theme.DARK -> UI.colors.pureInverse + Theme.AUTO -> UI.colors.pure }, tint = when (theme) { Theme.LIGHT -> UI.colors.pureInverse Theme.DARK -> UI.colors.pure + Theme.AUTO -> UI.colors.pureInverse } ) { onSwitchTheme() diff --git a/app/src/main/java/com/ivy/wallet/ui/home/HomeViewModel.kt b/app/src/main/java/com/ivy/wallet/ui/home/HomeViewModel.kt index 76abe476bf..a8887533b9 100644 --- a/app/src/main/java/com/ivy/wallet/ui/home/HomeViewModel.kt +++ b/app/src/main/java/com/ivy/wallet/ui/home/HomeViewModel.kt @@ -139,6 +139,10 @@ class HomeViewModel @Inject constructor( val settings = ioThread { settingsDao.findFirst() } _theme.value = settings.theme + + //This method is used to restore the theme when user imports locally backed up data + loadNewTheme() + _name.value = settings.name _baseCurrencyCode.value = settings.currency @@ -163,7 +167,6 @@ class HomeViewModel @Inject constructor( ).toDouble() } - val incomeExpensePair = ioThread { calculateWalletIncomeExpense( walletDAOs = walletDAOs, @@ -197,6 +200,10 @@ class HomeViewModel @Inject constructor( } } + private fun loadNewTheme() { + ivyContext.switchTheme(_theme.value) + } + fun setUpcomingExpanded(expanded: Boolean) { _upcomingExpanded.value = expanded } @@ -234,7 +241,8 @@ class HomeViewModel @Inject constructor( val newSettings = currentSettings.copy( theme = when (currentSettings.theme) { Theme.LIGHT -> Theme.DARK - Theme.DARK -> Theme.LIGHT + Theme.DARK -> Theme.AUTO + Theme.AUTO -> Theme.LIGHT } ) settingsDao.save(newSettings) diff --git a/app/src/main/java/com/ivy/wallet/ui/loan/LoanViewModel.kt b/app/src/main/java/com/ivy/wallet/ui/loan/LoanViewModel.kt index eb6599179b..862919a7a1 100644 --- a/app/src/main/java/com/ivy/wallet/ui/loan/LoanViewModel.kt +++ b/app/src/main/java/com/ivy/wallet/ui/loan/LoanViewModel.kt @@ -235,6 +235,7 @@ class LoanViewModel @Inject constructor( ) } is LoanScreenEvent.OnReordered -> { + reorder(event.reorderedList) _state.value = _state.value.copy( loans = event.reorderedList ) diff --git a/app/src/main/java/com/ivy/wallet/ui/loandetails/LoanDetailsScreen.kt b/app/src/main/java/com/ivy/wallet/ui/loandetails/LoanDetailsScreen.kt index 4e181586d2..6335c17498 100644 --- a/app/src/main/java/com/ivy/wallet/ui/loandetails/LoanDetailsScreen.kt +++ b/app/src/main/java/com/ivy/wallet/ui/loandetails/LoanDetailsScreen.kt @@ -249,7 +249,7 @@ private fun BoxWithConstraintsScope.UI( onDeleteLoan() } - ConfirmationModal( + ProgressModal( title = "Confirm Account Change", description = "Please wait, re-calculating all loan records", visible = waitModalVisible diff --git a/app/src/main/java/com/ivy/wallet/ui/settings/SettingsScreen.kt b/app/src/main/java/com/ivy/wallet/ui/settings/SettingsScreen.kt index d25e15b5d2..b8c2f82ce6 100644 --- a/app/src/main/java/com/ivy/wallet/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/ivy/wallet/ui/settings/SettingsScreen.kt @@ -55,6 +55,7 @@ fun BoxWithConstraintsScope.SettingsScreen(screen: Settings) { val lockApp by viewModel.lockApp.observeAsState(false) val showNotifications by viewModel.showNotifications.collectAsState() val startDateOfMonth by viewModel.startDateOfMonth.observeAsState(1) + val progressState by viewModel.progressState.collectAsState() val nameLocalAccount by viewModel.nameLocalAccount.observeAsState() @@ -70,6 +71,7 @@ fun BoxWithConstraintsScope.SettingsScreen(screen: Settings) { opSync = opSync, lockApp = lockApp, showNotifications = showNotifications, + progressState = progressState, nameLocalAccount = nameLocalAccount, startDateOfMonth = startDateOfMonth, @@ -81,6 +83,9 @@ fun BoxWithConstraintsScope.SettingsScreen(screen: Settings) { onSync = viewModel::sync, onLogout = viewModel::logout, onLogin = viewModel::login, + onBackupData = { + viewModel.exportToZip(context) + }, onExportToCSV = { viewModel.exportToCSV(context) }, @@ -107,6 +112,7 @@ private fun BoxWithConstraintsScope.UI( lockApp: Boolean, showNotifications: Boolean = true, + progressState: Boolean = false, nameLocalAccount: String?, startDateOfMonth: Int = 1, @@ -118,6 +124,7 @@ private fun BoxWithConstraintsScope.UI( onSync: () -> Unit, onLogout: () -> Unit, onLogin: () -> Unit, + onBackupData: () -> Unit = {}, onExportToCSV: () -> Unit = {}, onSetLockApp: (Boolean) -> Unit = {}, onSetShowNotifications: (Boolean) -> Unit = {}, @@ -208,9 +215,18 @@ private fun BoxWithConstraintsScope.UI( Spacer(Modifier.height(12.dp)) + SettingsDefaultButton( + icon = R.drawable.ic_export_csv, + text = "Backup Data", + ) { + onBackupData() + } + + Spacer(Modifier.height(12.dp)) + SettingsPrimaryButton( icon = R.drawable.ic_export_csv, - text = "Import CSV", + text = "Import Data", backgroundGradient = GradientGreen ) { nav.navigateTo( @@ -381,6 +397,12 @@ private fun BoxWithConstraintsScope.UI( onDeleteAllUserData() } ) + + ProgressModal( + title = "Exporting Data", + description = "Please wait, exporting data", + visible = progressState + ) } @Composable diff --git a/app/src/main/java/com/ivy/wallet/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/ivy/wallet/ui/settings/SettingsViewModel.kt index 7a0d1474b2..f5e6b6135a 100644 --- a/app/src/main/java/com/ivy/wallet/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/ivy/wallet/ui/settings/SettingsViewModel.kt @@ -9,6 +9,7 @@ import com.ivy.wallet.base.* import com.ivy.wallet.logic.LogoutLogic import com.ivy.wallet.logic.csv.ExportCSVLogic import com.ivy.wallet.logic.currency.ExchangeRatesLogic +import com.ivy.wallet.logic.zip.ExportZipLogic import com.ivy.wallet.model.analytics.AnalyticsEvent import com.ivy.wallet.model.entity.User import com.ivy.wallet.network.FCMClient @@ -23,6 +24,7 @@ import com.ivy.wallet.sync.IvySync import com.ivy.wallet.ui.IvyActivity import com.ivy.wallet.ui.IvyWalletCtx import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch @@ -42,7 +44,8 @@ class SettingsViewModel @Inject constructor( private val ivyAnalytics: IvyAnalytics, private val exchangeRatesLogic: ExchangeRatesLogic, private val logoutLogic: LogoutLogic, - private val sharedPrefs: SharedPrefs + private val sharedPrefs: SharedPrefs, + private val exportZipLogic: ExportZipLogic ) : ViewModel() { private val _user = MutableLiveData() @@ -63,6 +66,9 @@ class SettingsViewModel @Inject constructor( private val _showNotifications = MutableStateFlow(true) val showNotifications = _showNotifications.asStateFlow() + private val _progressState = MutableStateFlow(false) + val progressState = _progressState.asStateFlow() + private val _startDateOfMonth = MutableLiveData() val startDateOfMonth = _startDateOfMonth @@ -169,6 +175,31 @@ class SettingsViewModel @Inject constructor( } } + fun exportToZip(context: Context) { + ivyContext.createNewFile( + "Ivy Wallet (${ + timeNowUTC().formatNicelyWithTime(noWeekDay = true) + }).zip" + ) { fileUri -> + viewModelScope.launch(Dispatchers.IO) { + TestIdlingResource.increment() + + _progressState.value = true + exportZipLogic.exportToFile(context = context, zipFileUri = fileUri) + _progressState.value = false + + uiThread { + (context as IvyActivity).shareZipFile( + fileUri = fileUri + ) + } + + TestIdlingResource.decrement() + } + } + } + + fun setStartDateOfMonth(startDate: Int) { if (startDate in 1..31) { TestIdlingResource.increment() diff --git a/app/src/main/java/com/ivy/wallet/ui/theme/components/CustomExchangeRateCard.kt b/app/src/main/java/com/ivy/wallet/ui/theme/components/CustomExchangeRateCard.kt new file mode 100644 index 0000000000..39db79ba7b --- /dev/null +++ b/app/src/main/java/com/ivy/wallet/ui/theme/components/CustomExchangeRateCard.kt @@ -0,0 +1,102 @@ +package com.ivy.wallet.ui.theme.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.design.l0_system.UI +import com.ivy.design.l0_system.style +import com.ivy.wallet.R +import com.ivy.wallet.base.format +import com.ivy.wallet.ui.IvyWalletComponentPreview +import com.ivy.wallet.ui.theme.Orange + + +@Composable +fun CustomExchangeRateCard( + modifier: Modifier = Modifier, + title: String = "Exchange Rate", + fromCurrencyCode: String, + toCurrencyCode: String, + exchangeRate: Double, + onClick: () -> Unit, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .clip(UI.shapes.r4) + .background(UI.colors.medium, UI.shapes.r4) + .clickable(onClick = onClick) + .padding(vertical = 20.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Spacer(Modifier.width(16.dp)) + + IvyIcon(icon = R.drawable.ic_currency) + + Spacer(Modifier.width(8.dp)) + + Column( + modifier = Modifier + .fillMaxWidth() + ) { + Text( + text = title, + style = UI.typo.b2.style( + fontWeight = FontWeight.ExtraBold, + color = UI.colors.pureInverse + ) + ) + + Spacer(Modifier.height(4.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = fromCurrencyCode, + style = UI.typo.b2.style( + fontWeight = FontWeight.ExtraBold, + color = Orange + ) + ) + IvyIcon(icon = R.drawable.ic_arrow_right, tint = Orange) + Text( + text = "$toCurrencyCode \t\t:\t\t", + style = UI.typo.nB2.style( + fontWeight = FontWeight.ExtraBold, + color = Orange + ) + ) + Text( + text = exchangeRate.format(4), + style = UI.typo.nB2.style( + fontWeight = FontWeight.ExtraBold, + color = Orange + ) + ) + } + } + } +} + +@Preview +@Composable +private fun Preview_OneTime() { + IvyWalletComponentPreview { + CustomExchangeRateCard( + fromCurrencyCode = "INR", + toCurrencyCode = "EUR", + exchangeRate = (86.2) + ) { + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ivy/wallet/ui/theme/modal/LoanRecordModal.kt b/app/src/main/java/com/ivy/wallet/ui/theme/modal/LoanRecordModal.kt index d583047ffe..e0679e73f8 100644 --- a/app/src/main/java/com/ivy/wallet/ui/theme/modal/LoanRecordModal.kt +++ b/app/src/main/java/com/ivy/wallet/ui/theme/modal/LoanRecordModal.kt @@ -436,6 +436,8 @@ private fun AccountsRow( } } + if (TestingContext.inTest) return //fix broken tests + LazyRow( modifier = modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, diff --git a/app/src/main/java/com/ivy/wallet/ui/theme/modal/ConfirmationModal.kt b/app/src/main/java/com/ivy/wallet/ui/theme/modal/ProgressModal.kt similarity index 97% rename from app/src/main/java/com/ivy/wallet/ui/theme/modal/ConfirmationModal.kt rename to app/src/main/java/com/ivy/wallet/ui/theme/modal/ProgressModal.kt index 62ee1dfa3e..5f95c3cae2 100644 --- a/app/src/main/java/com/ivy/wallet/ui/theme/modal/ConfirmationModal.kt +++ b/app/src/main/java/com/ivy/wallet/ui/theme/modal/ProgressModal.kt @@ -15,7 +15,7 @@ import com.ivy.wallet.ui.theme.Red import java.util.* @Composable -fun BoxWithConstraintsScope.ConfirmationModal( +fun BoxWithConstraintsScope.ProgressModal( id: UUID = UUID.randomUUID(), title: String, description: String, diff --git a/app/src/main/java/com/ivy/wallet/ui/theme/modal/edit/AmountModal.kt b/app/src/main/java/com/ivy/wallet/ui/theme/modal/edit/AmountModal.kt index 169b7744b9..e1fb197e6f 100644 --- a/app/src/main/java/com/ivy/wallet/ui/theme/modal/edit/AmountModal.kt +++ b/app/src/main/java/com/ivy/wallet/ui/theme/modal/edit/AmountModal.kt @@ -41,6 +41,7 @@ fun BoxWithConstraintsScope.AmountModal( visible: Boolean, currency: String, initialAmount: Double?, + decimalCountMax: Int = 2, Header: (@Composable () -> Unit)? = null, amountSpacerTop: Dp = 64.dp, dismiss: () -> Unit, @@ -48,7 +49,12 @@ fun BoxWithConstraintsScope.AmountModal( ) { var amount by remember(id) { mutableStateOf( - initialAmount?.takeIf { it != 0.0 }?.format(currency) ?: "" + if (currency.isNotEmpty()) + initialAmount?.takeIf { it != 0.0 }?.format(currency) + ?: "" + else + initialAmount?.takeIf { it != 0.0 }?.format(decimalCountMax) + ?: "" ) } @@ -109,6 +115,7 @@ fun BoxWithConstraintsScope.AmountModal( AmountInput( currency = currency, + decimalCountMax = decimalCountMax, amount = amount ) { amount = it @@ -125,7 +132,7 @@ fun BoxWithConstraintsScope.AmountModal( calculatorModalVisible = false }, onCalculation = { - amount = it.format(currency) + amount = if (currency.isNotEmpty()) it.format(currency) else it.format(decimalCountMax) } ) } @@ -165,8 +172,10 @@ fun AmountCurrency( fun AmountInput( currency: String, amount: String, - setAmount: (String) -> Unit -) { + decimalCountMax: Int = 2, + setAmount: (String) -> Unit, + + ) { var firstInput by remember { mutableStateOf(true) } AmountKeyboard( @@ -178,7 +187,8 @@ fun AmountInput( val formattedAmount = formatInputAmount( currency = currency, amount = amount, - newSymbol = it + newSymbol = it, + decimalCountMax = decimalCountMax ) if (formattedAmount != null) { setAmount(formattedAmount) diff --git a/app/src/main/java/com/ivy/wallet/ui/theme/transaction/TransactionCard.kt b/app/src/main/java/com/ivy/wallet/ui/theme/transaction/TransactionCard.kt index 0ef2757c83..2a15d47beb 100644 --- a/app/src/main/java/com/ivy/wallet/ui/theme/transaction/TransactionCard.kt +++ b/app/src/main/java/com/ivy/wallet/ui/theme/transaction/TransactionCard.kt @@ -22,10 +22,7 @@ import com.ivy.design.api.navigation import com.ivy.design.l0_system.UI import com.ivy.design.l0_system.style import com.ivy.wallet.R -import com.ivy.wallet.base.dateNowUTC -import com.ivy.wallet.base.formatNicely -import com.ivy.wallet.base.isNotNullOrBlank -import com.ivy.wallet.base.timeNowUTC +import com.ivy.wallet.base.* import com.ivy.wallet.model.TransactionType import com.ivy.wallet.model.entity.Account import com.ivy.wallet.model.entity.Category @@ -70,6 +67,9 @@ fun LazyItemScope.TransactionCard( val transactionCurrency = accounts.find { it.id == transaction.accountId }?.currency ?: baseCurrency + val toAccountCurrency = accounts.find { it.id == transaction.toAccountId }?.currency + ?: baseCurrency + Spacer(Modifier.height(20.dp)) TransactionHeaderRow( @@ -123,6 +123,17 @@ fun LazyItemScope.TransactionCard( amount = transaction.amount ) + if (transaction.type == TransactionType.TRANSFER && transaction.toAmount != null && toAccountCurrency != transactionCurrency) { + Text( + modifier = Modifier.padding(start = 68.dp), + text = transaction.toAmount.format(2) + " $toAccountCurrency", + style = UI.typo.nB2.style( + color = Gray, + fontWeight = FontWeight.Normal + ) + ) + } + if (transaction.dueDate != null && transaction.dateTime == null) { //Pay/Get button Spacer(Modifier.height(16.dp)) diff --git a/app/src/main/res/drawable/home_more_menu_auto_mode.xml b/app/src/main/res/drawable/home_more_menu_auto_mode.xml new file mode 100644 index 0000000000..885800ebb7 --- /dev/null +++ b/app/src/main/res/drawable/home_more_menu_auto_mode.xml @@ -0,0 +1,10 @@ + + + diff --git a/buildSrc/src/main/java/com/ivy/wallet/buildsrc/dependencies.kt b/buildSrc/src/main/java/com/ivy/wallet/buildsrc/dependencies.kt index c0c65b6b19..17c43f050f 100644 --- a/buildSrc/src/main/java/com/ivy/wallet/buildsrc/dependencies.kt +++ b/buildSrc/src/main/java/com/ivy/wallet/buildsrc/dependencies.kt @@ -22,8 +22,8 @@ import org.gradle.kotlin.dsl.project object Project { //Version - const val versionName = "2.3.4-halley" - const val versionCode = 94 + const val versionName = "3.0.0-universe" + const val versionCode = 100 //Compile SDK & Build Tools const val compileSdkVersion = 31 @@ -35,7 +35,7 @@ object Project { } object GlobalVersions { - const val compose = "1.1.0" + const val compose = "1.1.1" const val kotlinVersion = "1.6.10" } diff --git a/ivy-design/src/main/java/com/ivy/design/api/IvyDesign.kt b/ivy-design/src/main/java/com/ivy/design/api/IvyDesign.kt index 74e6742625..f818c99fe3 100644 --- a/ivy-design/src/main/java/com/ivy/design/api/IvyDesign.kt +++ b/ivy-design/src/main/java/com/ivy/design/api/IvyDesign.kt @@ -11,7 +11,7 @@ interface IvyDesign { fun typography(): IvyTypography - fun colors(theme: Theme): IvyColors + fun colors(theme: Theme,isDarkModeEnabled: Boolean): IvyColors fun shapes(): IvyShapes } \ No newline at end of file diff --git a/ivy-design/src/main/java/com/ivy/design/api/systems/IvyWalletDesign.kt b/ivy-design/src/main/java/com/ivy/design/api/systems/IvyWalletDesign.kt index 069248894b..df2279e011 100644 --- a/ivy-design/src/main/java/com/ivy/design/api/systems/IvyWalletDesign.kt +++ b/ivy-design/src/main/java/com/ivy/design/api/systems/IvyWalletDesign.kt @@ -110,7 +110,7 @@ abstract class IvyWalletDesign : IvyDesign { } } - override fun colors(theme: Theme): IvyColors { + override fun colors(theme: Theme, isDarkModeEnabled: Boolean): IvyColors { return when (theme) { Theme.LIGHT -> object : IvyColors { override val pure = White @@ -154,6 +154,7 @@ abstract class IvyWalletDesign : IvyDesign { override val isLight = false } + Theme.AUTO -> if(isDarkModeEnabled) colors(Theme.DARK,true) else colors(Theme.LIGHT,false) } } diff --git a/ivy-design/src/main/java/com/ivy/design/l0_system/IvyTheme.kt b/ivy-design/src/main/java/com/ivy/design/l0_system/IvyTheme.kt index 73ea62a881..8916d703ee 100644 --- a/ivy-design/src/main/java/com/ivy/design/l0_system/IvyTheme.kt +++ b/ivy-design/src/main/java/com/ivy/design/l0_system/IvyTheme.kt @@ -1,5 +1,6 @@ package com.ivy.design.l0_system +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material.Colors import androidx.compose.material.MaterialTheme import androidx.compose.material.Shapes @@ -38,7 +39,7 @@ fun IvyTheme( design: IvyDesign, content: @Composable () -> Unit ) { - val colors = design.colors(theme) + val colors = design.colors(theme, isSystemInDarkTheme()) val typography = design.typography() val shapes = design.shapes() diff --git a/ivy-design/src/main/java/com/ivy/design/l0_system/Theme.kt b/ivy-design/src/main/java/com/ivy/design/l0_system/Theme.kt index 26a660c3e6..61fe247690 100644 --- a/ivy-design/src/main/java/com/ivy/design/l0_system/Theme.kt +++ b/ivy-design/src/main/java/com/ivy/design/l0_system/Theme.kt @@ -1,12 +1,13 @@ package com.ivy.design.l0_system enum class Theme { - LIGHT, DARK; + LIGHT, DARK, AUTO; fun inverted(): Theme { return when (this) { LIGHT -> DARK DARK -> LIGHT + AUTO -> AUTO } } } \ No newline at end of file